diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c3e1344556e..5eaf715affa 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3658,6 +3658,15 @@ def _all_platforms() -> list[dict]: ``hermes setup gateway`` without needing the gateway to be running. Built-ins keep their dict shape; plugin entries are adapted to the same shape with ``_registry_entry`` holding the source. + + Platform-specific gating: some platforms can't be configured on + every host. Currently: + - Matrix is hidden on Windows. The [matrix] extra pulls + ``mautrix[encryption]`` -> ``python-olm``, which has no Windows + wheel and needs ``make`` + libolm to build from sdist. There's + no native Windows path that works, so we don't offer it in the + picker. Users who want Matrix on Windows can run hermes under + WSL. """ # Populate the registry so plugin platforms are visible. Idempotent. # Bundled platform plugins (``kind: platform``) auto-load unconditionally, @@ -3671,6 +3680,11 @@ def _all_platforms() -> list[dict]: logger.debug("plugin discovery failed during platform enumeration: %s", e) platforms = [dict(p) for p in _PLATFORMS] + + # Drop platforms that can't function on this host. See docstring. + if sys.platform == "win32": + platforms = [p for p in platforms if p.get("key") != "matrix"] + by_key = {p["key"]: p for p in platforms} try: diff --git a/pyproject.toml b/pyproject.toml index 68b2a38471b..118f30c501c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,25 +136,12 @@ termux = [ "hermes-agent[acp]", ] termux-all = [ - # Best-effort "install all" profile for Termux: include broad extras that - # are known to resolve on Android, while intentionally excluding extras that - # currently hard-fail from missing/broken Android wheels/toolchains. - # - # Excluded for now: - # - matrix (mautrix[encryption] -> python-olm build failures on Termux) - # - voice (faster-whisper chain requires ctranslate2/av builds not packaged) + # 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[messaging]", - "hermes-agent[slack]", - "hermes-agent[tts-premium]", - "hermes-agent[dingtalk]", - "hermes-agent[feishu]", "hermes-agent[google]", - # mistral: omitted from broad termux-all profile — `mistralai` PyPI package - # is currently quarantined (malicious 2.4.6 release). Users who explicitly - # want Voxtral STT/TTS can still `pip install hermes-agent[mistral]` - # directly once PyPI un-quarantines. - "hermes-agent[bedrock]", "hermes-agent[homeassistant]", "hermes-agent[sms]", "hermes-agent[web]", @@ -188,41 +175,36 @@ rl = [ ] yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"] all = [ - "hermes-agent[anthropic]", - "hermes-agent[exa]", - "hermes-agent[firecrawl]", - "hermes-agent[parallel-web]", - "hermes-agent[fal]", - "hermes-agent[edge-tts]", - "hermes-agent[modal]", - "hermes-agent[daytona]", - "hermes-agent[vercel]", - "hermes-agent[messaging]", - # matrix: python-olm (required by matrix-nio[e2e]) is upstream-broken on - # modern macOS (archived libolm, C++ errors with Clang 21+). On Linux the - # [matrix] extra's own marker pulls in the [e2e] variant automatically. - "hermes-agent[matrix]; sys_platform == 'linux'", + # 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. "hermes-agent[cron]", "hermes-agent[cli]", "hermes-agent[dev]", - "hermes-agent[tts-premium]", - "hermes-agent[slack]", "hermes-agent[pty]", - "hermes-agent[honcho]", "hermes-agent[mcp]", "hermes-agent[homeassistant]", "hermes-agent[sms]", "hermes-agent[acp]", - "hermes-agent[voice]", - "hermes-agent[dingtalk]", - "hermes-agent[feishu]", "hermes-agent[google]", - # mistral: omitted from [all] — `mistralai` PyPI package is currently - # quarantined (malicious 2.4.6 release on 2026-05-12). Pulling it from - # [all] would break every fresh install / AUR build / Docker build / CI - # run until PyPI un-quarantines. Users who explicitly want Voxtral STT/TTS - # can still `pip install hermes-agent[mistral]` once it's available again. - "hermes-agent[bedrock]", "hermes-agent[web]", "hermes-agent[youtube]", ] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 56a338ea069..e2fe765174c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -806,7 +806,14 @@ function Install-Dependencies { # current extras spec, NOT because they're equivalent in posture. if (Test-Path "uv.lock") { Write-Info "Trying tier: hash-verified (uv.lock) ..." - & $UvCmd sync --all-extras --locked + # Critical flag choice: `--extra all`, NOT `--all-extras`. + # --all-extras = every [project.optional-dependencies] key, + # bypassing the curated [all] extra. On Windows + # that means [matrix] -> python-olm (no wheel, + # needs `make` to build from sdist) and the + # install fails. + # --extra all = just the [all] extra's contents (curated). + & $UvCmd sync --extra all --locked if ($LASTEXITCODE -eq 0) { Write-Success "Main package installed (hash-verified via uv.lock)" $script:InstalledTier = "hash-verified (uv.lock)" @@ -822,53 +829,59 @@ function Install-Dependencies { $skipPipFallback = $false } - # Install main package. Tiered fallback so a single flaky git+https dep - # (atroposlib / tinker in the [rl] extra) doesn't silently drop - # dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is + # Install main package. Tiered fallback so a single flaky transitive + # doesn't silently drop everything. Each tier's stdout/stderr is # preserved — no Out-Null swallowing — so the user can see what failed. # - # Tier 1: [all] — everything, including RL git+https deps (best case). - # Tier 2: [all] minus a small list of currently-broken extras. The - # broken list is centralised in $brokenExtras below — when - # a package gets quarantined / yanked / pulled, add it here - # and the resolver no longer chokes on it. This is what saves - # the user from silently losing 10+ unrelated extras every - # time one upstream package breaks. - # Tier 3: [core-extras] synthesised locally — all PyPI-only extras we - # ship, also minus $brokenExtras. Drops [rl] and [matrix] - # (linux-only) which are the usual failure culprits. - # Tier 4: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly - # believe a user expects `hermes dashboard` / slash commands / - # cron / messaging platforms to work out of the box. - # Tier 5: bare `.` — last-resort so at least the core CLI launches. + # Tier 1: [all] — the curated extra in pyproject.toml. + # Tier 2: [all] minus the currently-broken extras list ($brokenExtras). + # Edit $brokenExtras below when something on PyPI breaks; this + # lets users keep the rest of [all] when one transitive is + # unavailable. The list of [all]'s contents is parsed from + # pyproject.toml at runtime — there is NO hand-mirrored copy + # to drift out of sync. + # Tier 3: bare `.` — last-resort so at least the core CLI launches. # Currently-broken extras. Edit this list when an upstream package # gets quarantined / yanked / breaks resolution. Empty means everything # in [all] should be installable; populate with the names of extras - # whose deps are temporarily unavailable to keep installs working - # for users. + # whose deps are temporarily unavailable. $brokenExtras = @() - $allExtras = @( - "modal","daytona","vercel","messaging","matrix","cron","cli","dev", - "tts-premium","slack","pty","honcho","mcp","homeassistant","sms", - "acp","voice","dingtalk","feishu","google","bedrock","web", - "youtube" - ) - $pypiExtras = @( - "web","mcp","cron","cli","voice","messaging","slack","dev","acp", - "pty","homeassistant","sms","tts-premium","honcho","google", - "bedrock","dingtalk","feishu","modal","daytona","vercel","youtube" - ) - $safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," - $safePypi = ($pypiExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + # Parse [project.optional-dependencies].all from pyproject.toml. + # tomllib is stdlib on Python 3.11+ which the bootstrap guarantees. + $pythonExeForParse = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) } + $allExtras = @() + if (Test-Path $pythonExeForParse) { + $parsed = & $pythonExeForParse -c @" +import re, sys, tomllib +try: + with open('pyproject.toml', 'rb') as fh: + data = tomllib.load(fh) + specs = data['project']['optional-dependencies']['all'] + out = [] + for s in specs: + m = re.search(r'hermes-agent\[([\w-]+)\]', s) + if m: out.append(m.group(1)) + print(','.join(out)) +except Exception: + sys.exit(1) +"@ 2>$null + if ($LASTEXITCODE -eq 0 -and $parsed) { + $allExtras = $parsed.Trim().Split(',') + } + } + if (-not $allExtras -or $allExtras.Count -eq 0) { + Write-Warn "Could not parse [all] from pyproject.toml; Tier 2 will be a no-op." + $safeAll = "all" + } else { + $safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join "," + } $brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" } $installTiers = @( - @{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" }, + @{ Name = "all"; Spec = ".[all]" }, @{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" }, - @{ Name = "PyPI-only extras (no git deps)"; Spec = ".[$safePypi]" }, - @{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" }, @{ Name = "core only (no extras)"; Spec = "." } ) $installed = $skipPipFallback diff --git a/scripts/install.sh b/scripts/install.sh index c54f9ad9ae0..aaa810f3c83 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1100,22 +1100,30 @@ install_deps() { # extras spec, NOT because they're equivalent in posture. if [ -f "uv.lock" ]; then log_info "Trying tier: hash-verified (uv.lock) ..." - log_info "(this resolves + downloads ~50 packages — first run on a fresh" - log_info " venv can take 1-5 minutes; uv prints progress below)" + log_info "(this resolves + downloads the curated [all] set — first run on a" + log_info " fresh venv can take 1-5 minutes; uv prints progress below)" # Stream uv's progress directly to the user instead of swallowing # it with `2>"$(mktemp)"`. Two reasons: - # 1. `--all-extras --locked` against a fresh venv has to pull - # every transitive (torch-class deps included) — silencing - # stderr makes the install look frozen for minutes on slow - # networks. Users see "Trying tier: hash-verified ..." and - # assume it's hung. + # 1. `--extra all --locked` against a fresh venv has to pull + # every transitive — silencing stderr makes the install + # look frozen for minutes on slow networks. Users see + # "Trying tier: hash-verified ..." and assume it's hung. # 2. The previous `2>"$(mktemp)"` substituted the path at # command-build time but never saved it, so on failure the # uv error message was unreachable — the user just got the # generic "lockfile may be stale" warning. + # + # Critical flag choice: `--extra all`, NOT `--all-extras`. + # --all-extras = every [project.optional-dependencies] key. + # This bypasses the curated `[all]` extra + # entirely and pulls e.g. [matrix] (which + # needs python-olm + make on Windows) and + # [rl] (git+https deps that fail offline). + # --extra all = install just the `[all]` extra's contents. + # This respects the curation in pyproject.toml. # uv's own progress UI handles TTY detection and downgrades # gracefully when stdout/stderr aren't terminals. - if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --all-extras --locked; then + if UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --extra all --locked; then log_success "Main package installed (hash-verified via uv.lock)" log_success "All dependencies installed" return 0 @@ -1131,57 +1139,63 @@ install_deps() { # fresh install all the way down to "core only" — the user should keep # everything else they signed up for. # - # Tier 1: [all] — everything, including RL git+https deps (best case). - # Tier 2: [all] minus the currently-broken extras list. Edit - # _BROKEN_EXTRAS below when something on PyPI breaks; this lets - # users keep voice/honcho/google/slack/matrix/etc. even when - # one transitive is unavailable. List the extras here as bare - # names from pyproject.toml [project.optional-dependencies] — - # the script translates them to `[a,b,c]` form below. - # Tier 3: PyPI-only extras (no git deps) — drops [rl] / [yc-bench] - # which are git+https and may fail in restricted networks. - # Tier 4: dashboard + core platforms — minimum viable interactive set. - # Tier 5: bare `.` — last-resort so at least the core CLI launches. - # - # Each tier's stderr is captured to a tempfile so we can show the user - # WHY the higher tier failed instead of silently dropping support. + # Tier 1: [all] — the curated extra in pyproject.toml. + # Tier 2: [all] minus the currently-broken extras list (_BROKEN_EXTRAS). + # Edit _BROKEN_EXTRAS below when something on PyPI breaks; this + # lets users keep the rest of [all] when one transitive is + # unavailable. The list of [all]'s contents is parsed from + # pyproject.toml at runtime — there is NO hand-mirrored copy + # to drift out of sync. If you want to change what [all] + # contains, edit pyproject.toml only. + # Tier 3: bare `.` — last-resort so at least the core CLI launches. + # Skipped tiers like "PyPI-only extras (no git deps)" used to + # exist to dodge [rl] / [matrix] git+sdist deps; those are no + # longer in [all] post-2026-05-12 lazy-install migration, so + # a separate PyPI-only tier had no remaining content. local _BROKEN_EXTRAS=() # populate when an extra becomes unresolvable - local _ALL_EXTRAS=( - modal daytona vercel messaging matrix cron cli dev tts-premium slack - pty honcho mcp homeassistant sms acp voice dingtalk feishu google - bedrock web youtube - ) - # Tier 2: all extras minus _BROKEN_EXTRAS - local _SAFE_EXTRAS=() - local _e _b _skip - for _e in "${_ALL_EXTRAS[@]}"; do - _skip=false - for _b in "${_BROKEN_EXTRAS[@]}"; do - if [ "$_e" = "$_b" ]; then _skip=true; break; fi + + # Parse [project.optional-dependencies].all from pyproject.toml. + # tomllib is stdlib on Python 3.11+ which uv's bootstrap guarantees. + # Falls back to a hand list if parse fails — defensive only. + local _ALL_EXTRAS_CSV + _ALL_EXTRAS_CSV="$( + "$PYTHON_PATH" - <<'PY' 2>/dev/null +import re, sys, tomllib +try: + with open("pyproject.toml", "rb") as fh: + data = tomllib.load(fh) + specs = data["project"]["optional-dependencies"]["all"] + extras = [] + for s in specs: + m = re.search(r"hermes-agent\[([\w-]+)\]", s) + if m: + extras.append(m.group(1)) + print(",".join(extras)) +except Exception as e: + print("", file=sys.stderr) + sys.exit(1) +PY + )" + if [ -z "$_ALL_EXTRAS_CSV" ]; then + log_warn "Could not parse [all] from pyproject.toml; falling back to .[all] only." + _ALL_EXTRAS_CSV="" + fi + + # Build "[all] minus broken" spec by filtering the parsed list. + local _SAFE_SPEC=".[all]" + if [ -n "$_ALL_EXTRAS_CSV" ] && [ "${#_BROKEN_EXTRAS[@]}" -gt 0 ]; then + local _SAFE_EXTRAS=() + local _e _b _skip + IFS=',' read -ra _ALL_EXTRAS_ARR <<< "$_ALL_EXTRAS_CSV" + for _e in "${_ALL_EXTRAS_ARR[@]}"; do + _skip=false + for _b in "${_BROKEN_EXTRAS[@]}"; do + if [ "$_e" = "$_b" ]; then _skip=true; break; fi + done + if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi done - if [ "$_skip" = false ]; then _SAFE_EXTRAS+=("$_e"); fi - done - local _SAFE_SPEC - _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" - # Tier 3: PyPI-only extras (no git deps), still skipping broken ones. - # Mirrors the install.ps1 list but excludes [rl] / [yc-bench] / [matrix] - # (matrix needs python-olm which fails to build on some hosts). - local _PYPI_EXTRAS=( - web mcp cron cli voice messaging slack dev acp pty homeassistant sms - tts-premium honcho google bedrock dingtalk feishu modal daytona vercel - youtube - ) - local _PYPI_SAFE=() - for _e in "${_PYPI_EXTRAS[@]}"; do - _skip=false - for _b in "${_BROKEN_EXTRAS[@]}"; do - if [ "$_e" = "$_b" ]; then _skip=true; break; fi - done - if [ "$_skip" = false ]; then _PYPI_SAFE+=("$_e"); fi - done - local _PYPI_SPEC - _PYPI_SPEC=".[$(IFS=,; echo "${_PYPI_SAFE[*]}")]" - local _TIER4_SPEC=".[web,mcp,cron,cli,messaging,dev]" + _SAFE_SPEC=".[$(IFS=,; echo "${_SAFE_EXTRAS[*]}")]" + fi ALL_INSTALL_LOG=$(mktemp) local _installed=false @@ -1201,10 +1215,8 @@ install_deps() { return 1 } - install_tier "all (with RL/matrix extras)" ".[all]" \ + install_tier "all" ".[all]" \ || install_tier "all minus known-broken (${_BROKEN_EXTRAS[*]:-none})" "$_SAFE_SPEC" \ - || install_tier "PyPI-only extras (no git deps)" "$_PYPI_SPEC" \ - || install_tier "dashboard + core platforms" "$_TIER4_SPEC" \ || install_tier "core only (no extras)" "." rm -f "$ALL_INSTALL_LOG" diff --git a/setup-hermes.sh b/setup-hermes.sh index 0b214b0633c..2aa773c1c9c 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -241,15 +241,21 @@ else # (the direct deps in pyproject.toml are exact-pinned, but # `uv pip install` re-resolves transitives fresh from PyPI). echo -e "${CYAN}→${NC} Using uv.lock for hash-verified installation..." - _UV_SYNC_LOG=$(mktemp) - if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --all-extras --locked 2>"$_UV_SYNC_LOG"; then + echo -e "${CYAN}→${NC} (first run on a fresh venv can take 1-5 minutes; uv prints progress below)" + # Critical flag choice: `--extra all`, NOT `--all-extras`. The + # latter installs every [project.optional-dependencies] key, + # bypassing the curated [all] extra and pulling backends like + # [matrix] (python-olm needs make on Windows) and [rl] (git+https + # deps that fail offline). See pyproject.toml's [all] for the + # curated set, and tools/lazy_deps.py for backends that install + # at first use. + # Also: stream stderr through directly so the user sees uv's + # progress UI instead of staring at a frozen prompt. + if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --extra all --locked; then echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)" - rm -f "$_UV_SYNC_LOG" else - echo -e "${YELLOW}⚠${NC} Lockfile sync failed (lockfile may be stale)." + echo -e "${YELLOW}⚠${NC} Lockfile sync failed (see uv output above)." echo -e "${YELLOW}⚠${NC} Falling back to PyPI resolve — transitives will NOT be hash-verified." - head -5 "$_UV_SYNC_LOG" | sed 's/^/ /' - rm -f "$_UV_SYNC_LOG" _try_install echo -e "${GREEN}✓${NC} Dependencies installed (transitives re-resolved, not hash-verified)" fi diff --git a/tests/hermes_cli/test_gateway_platform_gating.py b/tests/hermes_cli/test_gateway_platform_gating.py new file mode 100644 index 00000000000..c16875687ce --- /dev/null +++ b/tests/hermes_cli/test_gateway_platform_gating.py @@ -0,0 +1,61 @@ +"""Host-specific gating in ``hermes_cli.gateway._all_platforms()``. + +Some messaging platforms can't function on every host. The gate lives +in one place — ``_all_platforms()`` — so the setup wizard, the curses +gateway-config menu, and any future picker all see the same filtered +list. + +Currently: +- Matrix is hidden on Windows. The ``[matrix]`` extra pulls + ``mautrix[encryption]`` -> ``python-olm``, which has no Windows wheel + and needs ``make`` + libolm to build from sdist. There's no native + Windows path that works. +""" + +import sys + + +class TestMatrixHiddenOnWindows: + def test_matrix_present_on_linux(self, monkeypatch): + """Sanity: matrix is still in the picker on Linux/macOS.""" + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(gateway_mod.sys, "platform", "linux") + platforms = gateway_mod._all_platforms() + keys = {p["key"] for p in platforms} + assert "matrix" in keys, "matrix must be available on Linux" + + def test_matrix_present_on_macos(self, monkeypatch): + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(gateway_mod.sys, "platform", "darwin") + platforms = gateway_mod._all_platforms() + keys = {p["key"] for p in platforms} + assert "matrix" in keys, "matrix must be available on macOS" + + def test_matrix_hidden_on_windows(self, monkeypatch): + """The actual gate: matrix must NOT appear on Windows.""" + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(gateway_mod.sys, "platform", "win32") + platforms = gateway_mod._all_platforms() + keys = {p["key"] for p in platforms} + assert "matrix" not in keys, ( + "matrix must be hidden on Windows — python-olm has no " + "Windows wheel and no native build path" + ) + + def test_other_platforms_unaffected_on_windows(self, monkeypatch): + """Gating must only drop matrix, not collateral damage.""" + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(gateway_mod.sys, "platform", "win32") + platforms = gateway_mod._all_platforms() + keys = {p["key"] for p in platforms} + # A representative sample of platforms that have no Windows + # blockers — picker should still surface them. + for must_have in ("telegram", "discord", "slack", "mattermost"): + assert must_have in keys, ( + f"{must_have} disappeared from Windows picker — gate is " + "over-filtering" + ) diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 27a1002b56c..87dfc192ab7 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -11,22 +11,73 @@ def _load_optional_dependencies(): return project["optional-dependencies"] -def test_matrix_extra_linux_only_in_all(): - """mautrix[encryption] depends on python-olm which is upstream-broken on - modern macOS (archived libolm, C++ errors with Clang 21+). The [matrix] - extra is included in [all] but gated to Linux via a platform marker so - that ``hermes update`` doesn't fail on macOS.""" +def test_matrix_extra_not_in_all(): + """The [matrix] extra pulls `mautrix[encryption]` -> `python-olm`, + which has Linux-only wheels and no native build path on Windows or + 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. + """ optional_dependencies = _load_optional_dependencies() - assert "matrix" in optional_dependencies - # Must NOT be unconditional — python-olm has no macOS wheels. - assert "hermes-agent[matrix]" not in optional_dependencies["all"] - # Must be present with a Linux platform marker. - linux_gated = [ + 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. + matrix_in_all = [ dep for dep in optional_dependencies["all"] - if "matrix" in dep and "linux" in dep + if "matrix" in dep ] - assert linux_gated, "expected hermes-agent[matrix] with sys_platform=='linux' marker in [all]" + 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_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]. + + 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. + """ + 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", + "honcho", "hindsight", + } + 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(): diff --git a/uv.lock b/uv.lock index 5051fdf0727..713cd588fd6 100644 --- a/uv.lock +++ b/uv.lock @@ -1978,50 +1978,22 @@ acp = [ all = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, - { name = "aiohttp-socks", marker = "sys_platform == 'linux'" }, - { name = "aiosqlite", marker = "sys_platform == 'linux'" }, - { name = "alibabacloud-dingtalk" }, - { name = "anthropic" }, - { name = "asyncpg", marker = "sys_platform == 'linux'" }, - { name = "boto3" }, - { name = "daytona" }, { name = "debugpy" }, - { name = "dingtalk-stream" }, - { name = "discord-py", extra = ["voice"] }, - { name = "edge-tts" }, - { name = "elevenlabs" }, - { name = "exa-py" }, - { name = "fal-client" }, { name = "fastapi" }, - { name = "faster-whisper" }, - { name = "firecrawl-py" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, - { name = "honcho-ai" }, - { name = "lark-oapi" }, - { name = "markdown", marker = "sys_platform == 'linux'" }, - { name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" }, { name = "mcp" }, - { name = "modal" }, - { name = "numpy" }, - { name = "parallel-web" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-split" }, { name = "pytest-xdist" }, - { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, - { name = "qrcode" }, { name = "ruff" }, { name = "simple-term-menu" }, - { name = "slack-bolt" }, - { name = "slack-sdk" }, - { name = "sounddevice" }, { name = "ty" }, { name = "uvicorn", extra = ["standard"] }, - { name = "vercel" }, { name = "youtube-transcript-api" }, ] anthropic = [ @@ -2138,25 +2110,16 @@ termux = [ termux-all = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, - { name = "alibabacloud-dingtalk" }, - { name = "boto3" }, - { name = "dingtalk-stream" }, - { name = "discord-py", extra = ["voice"] }, - { name = "elevenlabs" }, { name = "fastapi" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "honcho-ai" }, - { name = "lark-oapi" }, { name = "mcp" }, { name = "ptyprocess", marker = "sys_platform != 'win32'" }, { name = "python-telegram-bot", extra = ["webhooks"] }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, - { name = "qrcode" }, { name = "simple-term-menu" }, - { name = "slack-bolt" }, - { name = "slack-sdk" }, { name = "uvicorn", extra = ["standard"] }, ] tts-premium = [ @@ -2213,47 +2176,23 @@ requires-dist = [ { 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 = ["anthropic"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["bedrock"], marker = "extra == 'termux-all'" }, { 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 = ["daytona"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["dingtalk"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["edge-tts"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["exa"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["fal"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["feishu"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["firecrawl"], 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 == 'all'" }, { name = "hermes-agent", extras = ["honcho"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["matrix"], marker = "sys_platform == 'linux' and extra == 'all'" }, { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["mcp"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["messaging"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["modal"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["parallel-web"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["pty"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["slack"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["slack"], marker = "extra == 'termux-all'" }, { 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 = ["tts-premium"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["tts-premium"], marker = "extra == 'termux-all'" }, - { name = "hermes-agent", extras = ["vercel"], marker = "extra == 'all'" }, - { name = "hermes-agent", extras = ["voice"], marker = "extra == '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'" },