Pārlūkot izejas kodu

Follow the Hermes checkout instead of asserting against it

Pulling Hermes surfaced two breaks that would otherwise have stayed
silent, plus the setup trap that hid them.

send_multiple_images returned None where the base class now returns
SendResult — an incompatible override. _post_attachment_message already
produced that value; it was being discarded at the call site.

test_adapter_creation pinned the platform value to a literal, but Hermes
resolves a bundled plugin's manifest name to its directory name by design
(_missing_ in gateway/config.py), so chatto-platform reads back as
hermes-chatto-plugin. The assertion now goes through Platform() and will
follow that mapping instead of reading as a plugin regression.

The docs also named packages that stopped existing (pyqwest, connectrpc,
opentelemetry, protobuf-py-ext) and a vendor/ size that was off by 4x.
Recorded what the tree actually holds, and why the httpx chain stays
vendored despite Hermes providing it: hermes_standalone_sender_fn runs
out-of-process with only vendor/ on sys.path. Verified by deleting those
copies in a bare interpreter — the import dies on httpx.

Adds the rule for checking the plugins/platforms symlink, since a stale
target runs a different checkout than the one being edited and every
symptom looks like something else.
Paul Klumpp 14 stundas atpakaļ
vecāks
revīzija
05f3c6e598
7 mainītis faili ar 195 papildinājumiem un 53 dzēšanām
  1. 98 6
      AGENTS.md
  2. 4 4
      README.md
  3. 69 29
      VENDORING.md
  4. 4 6
      adapter.py
  5. 9 3
      test_adapter.py
  6. 7 0
      vendor_chattolib.sh
  7. 4 5
      vendor_path.py

+ 98 - 6
AGENTS.md

@@ -27,10 +27,12 @@ consumption — because the bot is a Chatto client just like the browser. Read
 how the platform itself models a problem before inventing our own shape (its
 presence-overlay pattern is what shaped the roster projection below).
 
-The vendored copy is upstream `chattolib[realtime]` 0.5.0b4 (realtime
-protocol v2); the matching server floor is Chatto v0.5.0-alpha.4. Refreshing `vendor/`
-via `vendor_chattolib.sh` can move that floor (see VENDORING.md) — after each
-refresh, restate the new version here and in README.md.
+The vendored copy is upstream `chattolib[realtime]` 0.5.0b13 (realtime
+protocol v2). chattolib versions track the Chatto server release they target,
+so this copy pairs with Chatto v0.5.0-beta.13, which is also the server floor
+this build claims to support. Refreshing `vendor/` via `vendor_chattolib.sh`
+can move that floor (see VENDORING.md) — after each refresh, restate the new
+version here and in README.md.
 
 **Never edit vendored files.** All changes to `vendor/` must come through
 `vendor_chattolib.sh` (a full refresh). If a vendored file needs a fix,
@@ -38,6 +40,49 @@ either update the upstream library and re-run the refresh, or apply the
 fix in the adapter code (not in `vendor/`). The dead-field sweep command
 excludes `vendor/` for this reason.
 
+### Why `vendor/` exists, and what goes in it
+
+The plugin ships its whole dependency tree because `hermes plugin install` does
+not install Python packages. `plugin.yaml` may declare `python_dependencies`,
+but Hermes only validates them and prints an install hint — it never resolves
+them (`hermes_cli/plugins.py`, `_warn_python_dependencies`: *"a declaration
+seam ONLY … NEVER auto-installs them"*). Without a vendored tree the plugin
+dies on `import chattolib` at first gateway start, and only a log warning says
+why. So vendoring is not a preference; it is what makes the one-line install
+work.
+
+Within that constraint, **`vendor/` carries what chattolib needs to be
+importable, and it does not stop there.** Two different rules decide what
+lands in the tree, and they pull in opposite directions:
+
+| Vendored | Why |
+| --- | --- |
+| `chattolib` | The library itself. Nothing else provides it. |
+| `protobuf` | **Hermes does not ship it in the default set** — only optional extras (`google`, `modal`, `qdrant-client`, `mem0ai`, …) pull it in, so a plain install has none. It is also version-coupled: chattolib's generated `_pb` code is gencode 7.35.1, and protobuf ≥ 5.27 refuses to load gencode newer than its runtime at import time. A host protobuf is a guaranteed `VersionError`, not a soft mismatch. |
+| `websockets` | The `[realtime]` extra's transport. Hermes pins 15.0.1, which satisfies chattolib's `>=13.0`, but it shares the binary-package branch with protobuf and costs little next to it. |
+
+The remaining packages — `httpx`, `httpcore`, `h11`, `anyio`, `idna`,
+`certifi`, `typing_extensions` (~4.2 MB) — Hermes *does* provide, so keeping
+them in `vendor/` looks like duplication and has been re-litigated more than
+once. They stay because of the cron sender: `hermes_standalone_sender_fn`
+delivers scheduled messages **out of process, in no Hermes venv**, and there
+`vendor/common` is the only thing on `sys.path`. Dropping those copies to save
+4 MB breaks exactly the path that has no other supply. `vendor_path.py`
+**appends** `common/` so that in-process the host's copies win anyway — the
+duplicates are inert where Hermes runs and load-bearing where it does not.
+
+So the invariant is not "vendor only what Hermes lacks". It is: **whatever
+lands in `vendor/` must make chattolib importable in a bare Python process
+with nothing else on `sys.path`.** Every package `vendor_chattolib.sh` resolves
+today satisfies that; any future pruning proposal has to argue against this
+invariant, not against the repository size.
+
+Do not "optimise" this by dropping protobuf or reaching for host packages:
+the failure mode is an import-time crash, not a degraded feature. If Hermes
+ever gains a protobuf default dependency, `vendor_chattolib.sh` and this
+section need revisiting together — re-derive the answer rather than assuming
+the pins quoted here still hold, since they drift with every Hermes release.
+
 ## README.md is the human README
 
 **Two audiences: AGENTS.md is read by agents with this source tree open;
@@ -197,6 +242,48 @@ first, everything else builds on it:
      hermes-agent/plugins/platforms/hermes-chatto-plugin
    ```
 
+### Verify the symlink actually points here — before trusting any result
+
+**Hermes loads the plugin through this symlink, so a stale target silently
+runs a different checkout than the one you are editing.** Every symptom is
+plausible as something else, which is what makes it expensive:
+
+- tests import `gateway.*` from Hermes, and the failing assertion reads the
+  *linked* tree's `plugin.yaml`, not yours
+- a `vendor/` refresh appears to do nothing, because the tree that gets
+  exercised is the old one
+- a real gateway run serves an old chattolib while the source you just fixed
+  looks correct
+
+The directory name is what Hermes uses as the platform identity, and it is
+**not** the same string as `plugin.yaml`'s `name:` (`chatto-platform`). Hermes
+resolves the manifest name to the directory name on purpose — see `_missing_`
+in `gateway/config.py` — so `Platform("chatto-platform").value` is
+`"hermes-chatto-plugin"`. Tests must assert against
+`Platform(ChattoConstants.PLATFORM_NAME).value` rather than a literal, so a
+Hermes change to that mapping does not read as a plugin regression.
+
+Check the link whenever tests fail in a way the source does not explain, and
+whenever you return to a checkout after working elsewhere:
+
+```bash
+readlink -f hermes-agent/plugins/platforms/hermes-chatto-plugin
+grep -E '^Version' hermes-agent/plugins/platforms/hermes-chatto-plugin/vendor/common/chattolib-*.dist-info/METADATA
+```
+
+Both must name the tree you are editing and the chattolib version you just
+vendored. To repoint the link (the old target keeps its own working tree, so
+nothing is lost by moving it):
+
+```bash
+ln -sfn /absolute/path/to/this/checkout \
+  hermes-agent/plugins/platforms/hermes-chatto-plugin
+```
+
+Use an **absolute** path. A relative link resolves against
+`plugins/platforms/`, so it survives being copied between checkouts only by
+accident.
+
 ### Tests
 
 `adapter.py` imports from `gateway.*`, which lives in the Hermes Agent rather
@@ -205,10 +292,15 @@ than this repo. Without its source on the `PYTHONPATH`, collection dies with
 With the layout above:
 
 ```bash
-PYTHONPATH=/path/to/hermes-agent uv run --with pyyaml pytest -q
+PYTHONPATH=/path/to/hermes-agent uv run --with pyyaml --with "ruamel.yaml==0.18.17" pytest -q
 ```
 
-`gateway.config` needs `pyyaml`, which is not among our dev dependencies.
+Two Hermes dependencies are not among our own, and both are needed at import
+time: `pyyaml` (for `gateway.config`) and `ruamel.yaml` (for
+`hermes_yaml`, which `utils.py` imports on the way to `platform_config`). The
+list grows whenever Hermes promotes one of its packages from an extra to a core
+dependency — a missing one shows up as a collection error, not a test failure,
+so re-check this command whenever the Hermes checkout moves.
 
 ### Readability
 

+ 4 - 4
README.md

@@ -4,7 +4,7 @@ A [Chatto](https://github.com/chattocorp/chatto) platform adapter plugin for [He
 
 All API interaction goes through [chattolib](https://github.com/TheCodemancerLtd/chattolib): a persistent WebSocket realtime connection delivers inbound events, ConnectRPC over HTTP handles outbound messages, reactions, uploads and more. Messages are processed through the full Hermes Agent pipeline (tool use, memory, reasoning) and answered in real time.
 
-Tested against Chatto v0.5.0-alpha.4 with the vendored `chattolib[realtime]` 0.5.0b4 (realtime protocol v2). Chatto servers below v0.5.0-alpha.4 are not supported by this build.
+Tested against Chatto v0.5.0-beta.13 with the vendored `chattolib[realtime]` 0.5.0b13 (realtime protocol v2). chattolib versions track the Chatto server release they target, so this build pairs with Chatto v0.5.0-beta.13; Chatto servers older than v0.5.0-beta.13 are not supported by this build.
 
 Before setup, here's the part most people want to know: how Hermes behaves once it's in your Chatto instance.
 
@@ -58,7 +58,7 @@ Capabilities natively implemented by the Chatto plugin adapter:
 3. **Room membership** — the bot account must be a member of any room where you want it to respond; the adapter picks up every room the account has joined. Membership can be granted natively in Chatto (invite the bot account), or by DMing the bot `/join <room-id or #name>` (see [Managing Rooms over DM](#managing-rooms-over-dm)). For DMs, simply start a direct message with the bot.
 4. **Network access** — the Hermes host must reach the Chatto server URL over HTTPS (or HTTP) and establish a WebSocket connection to `/api/realtime`.
 
-> **Info:** The adapter uses WebSocket protocol v2 (compatible with Chatto v0.5.0-alpha.4+). Ensure your Chatto server is up to date.
+> **Info:** The adapter uses WebSocket protocol v2 (compatible with Chatto v0.5.0-beta.13+). Ensure your Chatto server is up to date.
 
 ## Install
 
@@ -402,7 +402,7 @@ location /api/realtime {
 
 **Cause**: The Chatto server is running an older version that doesn't support realtime protocol v2.
 
-**Fix**: The adapter uses protocol v2, supported by Chatto v0.5.0-alpha.4 and later. Upgrade your Chatto server:
+**Fix**: The adapter uses protocol v2, supported by Chatto v0.5.0-beta.13 and later. Upgrade your Chatto server:
 
 ```bash
 curl -s https://chat.example.com/api/version | jq .
@@ -441,7 +441,7 @@ grep -i "chatto\|websocket\|realtime" ~/.hermes/logs/gateway.log | tail -30
 ## Notes
 
 - **Self-hosted friendly**: Works with any self-hosted Chatto instance. No cloud account or subscription required.
-- **Minimal dependencies**: Only requires `chattolib[realtime]==0.5.0b4`, which is vendored — nothing to install.
+- **Minimal dependencies**: Only requires `chattolib[realtime]==0.5.0b13`, which is vendored — nothing to install.
 - **No message backfill**: Events missed while the gateway is down are gone — Chatto keeps no replay for the bot. Redelivered events after a reconnect are recognised and processed only once; anything genuinely new starts from the moment of reconnection.
 
 ## Acknowledgements

+ 69 - 29
VENDORING.md

@@ -13,25 +13,26 @@ dependency tree.
 
 ## Layout
 
-Three of the vendored packages contain compiled extension modules and are
-therefore platform-specific: `pyqwest` (~15 MB, the HTTP core used by
-`connectrpc`), `protobuf` and `protobuf-py-ext`. Everything else is pure
-Python. The tree is split accordingly, so the shared part is stored once:
+Two of the vendored packages contain compiled extension modules and are
+therefore platform-specific: `protobuf` (its `google/_upb` runtime) and
+`websockets` (its `speedups` mask helper). Everything else is pure Python. The
+tree is split accordingly, so the shared part is stored once:
 
 ```
 vendor/
 ├── __init__.py
-├── common/                    # pure-Python packages (~8 MB), all platforms
+├── common/                    # pure-Python packages (5.8 MB), all platforms
 │   ├── chattolib/
-│   ├── connectrpc/
 │   ├── httpx/ httpcore/ h11/ anyio/ idna/ certifi/
-│   └── opentelemetry/ protobuf/ typing_extensions.py
-└── platform/                  # compiled extensions, ~16 MB per platform
-    ├── linux-x86_64/          #   pyqwest/, protobuf_ext/, google/
+│   └── typing_extensions.py
+└── platform/                  # compiled extensions, 2.3–3.9 MB per platform
+    ├── linux-x86_64/          #   google/ websockets/
     ├── linux-aarch64/
     └── macos-arm64/
 ```
 
+Total: 14.4 MB on disk (~9.6 MB tracked by git, the `.so` files compress well).
+
 `vendor_path.py` derives the platform tag from `platform.system()` /
 `platform.machine()` at import time and puts both directories on `sys.path`.
 Both `adapter.py` and `platform_config.py` call `setup_vendor_path()` before
@@ -48,16 +49,29 @@ So the host's pure-Python packages win, and our copies act as a fallback —
 which keeps out-of-process use (the cron sender, `hermes_standalone_sender_fn`)
 working in an environment without Hermes.
 
-The packages Hermes does *not* provide — `chattolib`, `connectrpc`, `pyqwest`,
-`protobuf`, `protobuf-py`, `protobuf-py-ext` — always come from `vendor/`. They
-are also the bulk of it: `pyqwest` alone is ~15 MB per platform, so dropping
-the packages Hermes already ships would save only ~2.7 MB of ~59 MB and buy a
-dependency on Hermes' exact pins. Not worth it.
-
-chattolib requires `httpx>=0.27` and `protobuf>=5.28`; connectrpc requires
-`protobuf-py==0.1.1` and `pyqwest>=0.5.1`. If a future Hermes release pins
-`httpx` below 0.27, the appended fallback no longer helps — the host copy would
-be found first and be too old.
+Note that Hermes' pins drift from ours (`anyio` 4.12.1 vs 4.15.1, `idna` 3.18
+vs 3.20, `certifi` 2026.5.20 vs 2026.7.22) — the append order is exactly what
+keeps those differences harmless.
+
+The packages Hermes does *not* provide — `chattolib` and the compiled
+`protobuf`/`websockets` — always come from `vendor/`.
+
+The rest (`httpx`, `httpcore`, `h11`, `anyio`, `idna`, `certifi`,
+`typing_extensions`, ~4.2 MB) Hermes *does* provide, so it is fair to ask why
+they are vendored at all. Because of the cron sender:
+`hermes_standalone_sender_fn` delivers scheduled messages out of process, in no
+Hermes venv, and there `vendor/common` is the only thing on `sys.path`. The
+append order means those copies are inert wherever Hermes runs and
+load-bearing where it does not.
+
+chattolib requires `httpx>=0.27`, `protobuf>=5.28` and (via the `[realtime]`
+extra) `websockets>=13.0`. Two of those floors are worth knowing about:
+`protobuf>=5.28` is wrong in practice — chattolib b13 ships gencode 7.35.1, and
+protobuf ≥ 5.27 refuses at import time to load gencode newer than its own
+runtime, so a host protobuf older than 7.35.1 raises `VersionError` rather
+than degrading. And if a future Hermes release pins `httpx` below 0.27, the
+appended fallback no longer helps — the host copy would be found first and be
+too old.
 
 If the current platform was not vendored, the import fails with an explicit
 message naming the available platforms — rather than a cryptic `dlopen` error
@@ -77,9 +91,19 @@ PLATFORMS="linux-x86_64 linux-aarch64 macos-arm64 windows-amd64" ./vendor_chatto
 
 # Pin a specific chattolib version (keep the [realtime] extra — without it
 # websockets is not vendored and the event stream cannot start)
-CHATTOLIB_SPEC="chattolib[realtime]==0.5.0a5.post1" ./vendor_chattolib.sh
+CHATTOLIB_SPEC="chattolib[realtime]==0.5.0b13" ./vendor_chattolib.sh
 ```
 
+The script passes `--only-binary :all: --no-binary chattolib`. Everything must
+come as a prebuilt wheel, because a source distribution of `pyqwest`,
+`protobuf` or `websockets` could not be built for a *foreign* platform — uv
+would build it for the host and silently drop host-architecture binaries into
+`vendor/platform/<other-tag>/`. chattolib itself is the documented exception: it
+is pure Python, so building it from an sdist yields identical,
+platform-independent files on any host. That exception is load-bearing, not
+cosmetic — upstream stopped publishing wheels after 0.5.0b4, so without it the
+resolver falls back to b4 and a refresh silently changes nothing.
+
 The script wipes `vendor/`, installs the dependency tree once per platform via
 `uv pip install --python-platform …`, splits the result with `vendor_split.py`,
 and verifies that `import chattolib` works on the machine you ran it on.
@@ -98,19 +122,33 @@ Afterwards: `git add vendor/`.
 | `windows-amd64`      | `x86_64-pc-windows-msvc`       | —                   |
 
 All seven are available as binary wheels upstream; only the default three are
-committed, to keep the repository at a reasonable size (~59 MB for `vendor/`).
+committed, to keep the repository at a reasonable size (14.4 MB for `vendor/`).
 
 ### Python versions
 
 The script resolves wheels for **Python 3.11** (`PYTHON_VERSION`), which is the
-project minimum. At that version the three binary packages still provide
-`cp310-abi3` wheels, and the stable ABI is forward compatible — so one build
-covers 3.11 through 3.14. Resolving for 3.12+ would instead pull
-version-specific wheels (`cp312-…`) that only work on that exact version.
+project minimum. What that buys differs per package, and the difference matters:
+
+| Package | Tag at 3.11 | Consequence |
+| --- | --- | --- |
+| `protobuf` | `abi3` (`_message.abi3.so`) | Stable ABI, forward compatible — one build covers 3.11 through 3.15. |
+| `websockets` | `cp311` (`speedups.cpython-311-…so`) | Version-specific. |
+
+So on Python 3.12+ the protobuf runtime still loads, and `chattolib` imports
+fine — but `websockets.speedups` is *not* importable and websockets silently
+falls back to its pure-Python `apply_mask` (it wraps the import in
+`try: … except ImportError:`). Verified on 3.12 and 3.14: chattolib and its
+realtime module import, `apply_mask` resolves to `websockets.utils`.
+
+That is a performance fallback, not a breakage — masking is a small part of
+frame handling. It is the price for keeping one build instead of one per minor
+version, and the reason `PYTHON_VERSION` must stay at the project minimum:
+raising it would pull `cp312-…`-style wheels and make the whole tree
+version-specific.
 
-**If you raise `PYTHON_VERSION`, check that the resulting `.so` files are still
-named `*.abi3.so`.** If they are not, the vendored tree silently becomes
-Python-version-specific.
+**If you raise `PYTHON_VERSION`, check the resulting `.so` names.** `*.abi3.so`
+for protobuf; anything under `websockets/` is tied to that exact CPython minor
+version regardless.
 
 ## Development without vendoring
 
@@ -119,7 +157,9 @@ pip install chattolib
 ```
 
 A system-installed chattolib is only used if `vendor/` is absent — the vendored
-copy is deliberately prepended to `sys.path` and therefore wins.
+copy is deliberately prepended to `sys.path` and therefore wins. Keep the
+installed version in step with `vendor/common/chattolib-*.dist-info/`, or tests
+will pass against one chattolib while the plugin ships another.
 
 ## License
 

+ 4 - 6
adapter.py

@@ -3141,7 +3141,7 @@ class ChattoAdapter(BasePlatformAdapter):
         images: list[tuple[str, str]],
         metadata: dict[str, Any] | None = None,
         human_delay: float = 0.0,
-    ) -> None:
+    ) -> SendResult:
         """Send a batch of images as ONE message with several attachments.
 
         The base implementation posts each image separately; a Chatto message
@@ -3156,13 +3156,12 @@ class ChattoAdapter(BasePlatformAdapter):
         BasePlatformAdapter override
         """
         if len(images or []) < 2:
-            await super().send_multiple_images(
+            return await super().send_multiple_images(
                 chat_id,
                 images,
                 metadata=metadata,
                 human_delay=human_delay,
             )
-            return
 
         asset_ids: list[str] = []
         captions: list[str] = []
@@ -3190,13 +3189,12 @@ class ChattoAdapter(BasePlatformAdapter):
             logger.warning(
                 "Chatto: no image survived upload, falling back to per-image delivery",
             )
-            await super().send_multiple_images(
+            return await super().send_multiple_images(
                 chat_id,
                 images,
                 metadata=metadata,
                 human_delay=human_delay,
             )
-            return
 
         if len(asset_ids) < len(images):
             logger.warning(
@@ -3205,7 +3203,7 @@ class ChattoAdapter(BasePlatformAdapter):
                 len(images),
             )
 
-        await self._post_attachment_message(
+        return await self._post_attachment_message(
             chat_id,
             asset_ids,
             "\n".join(captions) or None,

+ 9 - 3
test_adapter.py

@@ -57,7 +57,7 @@ from chattolib.types import (
     TimelinePage,
     User,
 )
-from gateway.config import PlatformConfig
+from gateway.config import Platform, PlatformConfig
 from gateway.platforms.base import (
     BasePlatformAdapter,
     CachedMedia,
@@ -285,8 +285,14 @@ class TestAdapterInstantiation:
         adapter = ChattoAdapter(cfg)
         assert adapter is not None
         # Platform members created dynamically from a plugin name carry the
-        # name upper-cased; the registered identity is the value.
-        assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
+        # name upper-cased. Which value they carry is Hermes' call, not ours:
+        # for a bundled plugin whose plugin.yaml `name:` differs from its
+        # directory, Hermes resolves the manifest name to the directory name,
+        # because that is what every value-based consumer keys on
+        # (gateway/config.py, `_missing_`). Ours differs ("chatto-platform" vs
+        # "hermes-chatto-plugin"), so assert the mapping rather than a literal —
+        # pinning the string here broke the moment Hermes added that alias.
+        assert adapter.platform.value == Platform(ChattoConstants.PLATFORM_NAME).value
 
     def test_adapter_max_message_length(self):
         """The framework chunks via max_message_length_for_chat(), which reads

+ 7 - 0
vendor_chattolib.sh

@@ -75,11 +75,18 @@ for tag in "${PLATFORM_LIST[@]}"; do
     # --only-binary :all: is required: a source distribution could not be built
     # for a foreign platform, and building it for the host would silently
     # produce artifacts for the wrong architecture.
+    #
+    # chattolib is the one exception: it is pure Python with no compiled
+    # extension, so building it from the sdist on any host yields identical,
+    # platform-independent files. It has to be an exception because upstream
+    # stopped publishing wheels after 0.5.0b4 — b5…b13 are sdist-only, and
+    # without this the resolver silently pins the vendored copy back to b4.
     uv pip install \
         --target "$BUILD_DIR/$tag" \
         --python-platform "$target" \
         --python-version "$PYTHON_VERSION" \
         --only-binary :all: \
+        --no-binary chattolib \
         --quiet \
         "$CHATTOLIB_SPEC"
 done

+ 4 - 5
vendor_path.py

@@ -1,8 +1,8 @@
 """Locate the vendored dependency tree matching this interpreter's platform.
 
 ``vendor/`` is split into a platform-independent part and one directory per
-supported platform, because three of the vendored packages (pyqwest, protobuf,
-protobuf-py-ext) ship compiled extension modules::
+supported platform, because two of the vendored packages (protobuf and
+websockets) ship compiled extension modules::
 
     vendor/
       common/                    pure-Python packages, shared by all platforms
@@ -14,9 +14,8 @@ protobuf-py-ext) ship compiled extension modules::
 The two directories enter ``sys.path`` differently, on purpose:
 
 * ``platform/<tag>`` is **prepended**. It holds the compiled extensions that
-  must match the vendored chattolib (pyqwest, protobuf, protobuf-py-ext);
-  falling back to a host copy of a different version would break the generated
-  protobuf code.
+  must match the vendored chattolib (protobuf, websockets); falling back to a
+  host copy of a different version would break the generated protobuf code.
 * ``common`` is **appended**. Most of it (httpx, anyio, httpcore, h11, idna,
   certifi, typing_extensions) is also shipped by the Hermes agent itself, and
   a plugin has no business shadowing the host's pinned versions — Hermes pins