Skip to content

M2-33 Reference adapter for Hermes Agent

No Date Ruling Reason
D-M2-33-1 2026-10-24 Distribution bothy-hermes-adapter, import package bothy_hermes_adapter in plugins/hermes-adapter/src, listed in 02 6. Exactly two modules import Hermes: platform.py (the runtime, class ServerPlatform) and hermes_config.py (Hermes' own YAML reader and writer for the installer, the credential resolution of 17.8, the enabled toolsets). Every other module depends on the protocol Runtime (and Hermes reports to RuntimeEvents) in runtime.py. Neither the adapter nor the SDK has a build backend before M7: tests, mypy and the Fake-Hermes harness import both from their src directories, and the adapter's runtime dependencies are exactly the SDK's. The lower bounds of cryptography, PyJWT and websockets in both projects are the versions of the pinned Hermes environment. Place: plugins/hermes-adapter/pyproject.toml, sdk/python/pyproject.toml, 09 17.2. Logic testable without Hermes, a small coupling surface held by the stub contract test; the SDK's earlier lower bounds would have upgraded three packages of the Hermes environment, which 11 11.3 forbids.
D-M2-33-2 2026-10-24 New keys with place "Hermes adapter" in 09 21: hermes_adapter.server_url, hermes_adapter.instance_id (the expected iss), hermes_adapter.token_file, hermes_adapter.dpop_key_file, hermes_adapter.max_message_bytes and hermes_adapter.attachment_text_max_bytes (defaults equal to message.max_content_bytes and agent.context_message_max_bytes, held by a test). The agent's ID, the expected aud, comes from getMe, which must name a principal of kind agent. Keys arrive flat in PlatformConfig.extra; a dotted key the adapter does not know is refused, keys without a dot are Hermes' own. The token lies in a file readable only by the profile's user; the state directory is <profile home>/<platform key> with mode 0700. Place: config.py, platform.py, 09 17.2, 21. 09 named no way to reach the server; the environment must not be read (17.2) and the configuration file must not hold the token.
D-M2-33-3 2026-10-24 SDK: an allow list that answers 404 refuses the event with adapter_error (a start event ends its run) and settles it; a network error, 429 or 5xx leaves it unsettled without an inbox entry. prepare refuses a run with trigger_kind command or interaction (failed, adapter_error, no start). Place: bothy_sdk/adapter.py, handler.py. A missing operation gives a visible reason instead of a run that waits until run.queued_max_age; a transient failure is retried by the redelivery. The adapter registers no commands and opens no run from an interaction.
D-M2-33-4 2026-10-24 The actual mode follows the table of 17.8 from provider and key type of Hermes' resolution: Anthropic API key organization, Anthropic OAuth personal (OAuth wins), another API-key provider organization; every other resolution (no credential, cloud or external-process credentials, OAuth of another provider, an error, an unknown answer) gives no mode with key_type: unknown. Evidence {provider, key_type, method: "resolved_credentials"}. Determined at intake, again before every startRun and resumeRun, and at every heartbeat; a change during a running turn interrupts it and ends the run failed billing_mode_mismatch. Place: actual_mode.py, hermes_config.py, handler.py, 09 17.8. 17.8 lists the determinable rows exhaustively; 10 7 counts an undeterminable mode as unequal to every target; BR-05 "change during the run".
D-M2-33-5 2026-10-24 The tool class is the highest class of the enabled toolsets (Hermes' own resolution of platform_toolsets): none gives chat, clarify and todo (no effect outside the conversation) give chat, every other toolset, an unknown name and an MCP server give host; the pinned version has no scoped toolset. The table names every toolset of the pin with class and reason, held by a test. Place: tool_class.py, 09 17.11. Spec gap of 17; fail closed. A turn that may only ask questions or keep a list stays in the lowest class.
D-M2-33-6 2026-10-24 The context reaches Hermes as the event's ephemeral channel prompt: a fixed note, the channel instructions (prompt and skills of getServedChannelConfig, operator runs, reloaded when settings_version is newer), then the SDK's rendering with trust marks and data blocks whose boundary is random per turn. already_delivered elements are left out; text attachments are inlined up to hermes_adapter.attachment_text_max_bytes, cut at a UTF-8 boundary and marked; other attachments and refused files are listed by name, type and size; transcripts keep the trust of their message. The user text is the trigger text plus the transcripts of its voice attachments. Place: context_format.py, 09 17.6. ADR 0054, SEC-AI-1, T-AI-01, T-AI-03; 09 17 mapped neither prompt nor skills; the SDK's rendering is shared with the other adapters.
D-M2-33-7 2026-10-24 Steps come from set_status_text (the pinned version never calls format_tool_event): a status sentence opens a tool_call step with the sentence as title, the next status, draft, answer or the end closes it; a format_tool_event call of a later version opens and closes steps with detail exactly {"preview": ...}. Costs per provider, model and currency from the hook post_api_request (token counts); currency is USD when Hermes states none; no live /metrics or /cost patches. When the server refuses costs as not enabled (before M2-22) the SDK ends the run once more without them. The title is set once after a completed run with session_mode = new (operator runs), with If-Match, only on an auto title, never retried. Place: live_box.py, handler.py, bothy_sdk/runs.py, 09 17.3. 17.10 progress_hooks; 03 names no fields of run_step_detail.v1; the prices the pinned version ships are in USD; a run must end even on a server without cost booking.
D-M2-33-8 2026-10-24 Follow-ups: at steer the text is read from getRunContext and passed into the running turn as busy input, reported delivered, and absorbed at the end of the turn that received it (the pinned version reports no absorption). At queue nothing is passed for a running run. A follow-up for a run in waiting_input is the answer to the open card in both modes: the SDK resumes the run with its token, the adapter disables the card's components and answers the prompt once: a clarify with the text, an approval with deny and a slash confirmation with cancel (a text is no approval); at steer the text of an approval or confirmation answer first reaches the blocked turn. One place decides whether a person's text enters a turn: only at steer and only while the turn runs, because at queue (and after the end) Hermes would start a new turn that no run covers. A steer that misses the last tool batch is Hermes' next user turn inside the same processing, before on_processing_complete, so the run covers it (measured against the pin, test_followup_during_the_final_answer_stays_in_the_run); the installer turns Hermes' own busy notices off (display.busy_ack_enabled: false and display.platforms.<key>.busy_steer_ack_enabled: false), which would otherwise be posted as messages of the run. After a model reply without tool calls (assistant_tool_call_count of post_api_request, an unknown count counts as none) no text enters the turn any more: Hermes reports that reply before it drains its last steer, and the server's follow-on run takes the follow-up. A reply with tool calls opens the gate again, so text Hermes sends before a tool call (the same way as the answer) does not end steering (test_followup_after_interim_commentary_is_steered). A person's text (trigger and follow-up) reaches Hermes with allow_gateway_control off, so it never runs a Hermes command or answers a Hermes prompt by itself; the adapter's own commands (/stop, /new, /resume) go out from its control sender, the slash-command admin the installer lists. interrupt makes the adapter degraded with busy_mode_invalid and without the follow-up capability. Place: handler.py, 09 17.5. 09 7, 10.4, 11.5, 11.6, 17.4, 17.5: a chat text starting with / must not bypass the server's command check or an approval card.
D-M2-33-9 2026-10-24 Cards: clarify cl:<id>:<idx> (a select cl:<id>:select above five answers), approval appr:<id>:<choice> with the buttons of 11.5 (approve_actions except deny, owner for always), slash confirmation sc:<choice>:<id> (Once and Cancel run_starter, Always owner); all single_use, the run waits with awaitRunInput. One resolution per prompt (a durable claim of <run>:<kind>:<Hermes ID> before the Hermes resolver, because a new process may reuse Hermes' IDs; a failed claim answers the interaction failed and the run keeps waiting), respondToInteraction acknowledged with the chosen label, no updateComponent after an interaction. The adapter's own /new is confirmed at once without a card. Model and choice pickers are absent from the pinned version and not built. Place: cards.py, handler.py, platform.py, store.py. 09 11, 17.3, SEC-AI-4, AG-15; the server disables the other single-use components in the interaction's transaction (11.3).
D-M2-33-10 2026-10-24 Sessions: new switches Hermes to a new session (/new with its confirmation) when the thread has one and reports the new ID with started: true; resume binds to the server's reference when Hermes' ID differs and reports a changed ID (compression) after the turn; bind switches to a new session and then /resume <id> --all from the adapter's control sender, so a missing session leaves the new one in place: taken over when the turn ran on the reference or on a session whose parent it is, else the report carries bind_failed: session_missing and started: true. Reports use If-Match with the payload's agent_session.version; 412 and 409 are logged and the run continues. Guest runs get a fresh session and no report. The thread's Hermes session (and its parent) is kept in the adapter's store, so a new process still switches when a thread already holds a session. Place: handler.py, platform.py, store.py, 09 17.4. 09 9.1, 9.5, 17.4, 17.10 session_switch; the session ID is known only from the hook of the next model request.
D-M2-33-11 2026-10-24 Heartbeat: online, or degraded with busy_mode_invalid and billing_evidence_unknown; runtime_version the Hermes version; reported_billing_mode and billing_modes_supported exactly the actual mode, both absent when unknown; evidence without value; reported_tool_class; capabilities components, title and followups; active_runs, adapter, runtime and protocol version from the SDK; never rate_limit or project_readiness. Place: heartbeat.py. D-L8: Hermes proves one mode per profile process; M2-20 note 3.
D-M2-33-12 2026-10-24 Installer python -m bothy_hermes_adapter install|switch-mode|check with the profile's Python: Hermes' own reader and writer before and after, a summary without values, the predicted mode, and the restart the operator must do; it never restarts a process and never reads a credential value (variables are judged by name). install writes the platform section, dm_policy: allowlist, the control sender as slash admin, the global display.busy_input_mode (interrupt refused), tool_progress: off, a placeholder home channel, and refuses another platform key. switch-mode organization needs the API key, refuses a login file of another tool in the profile's home, empties the OAuth variables and Hermes' allow list and sets auth.adopt_external_logins: false; personal empties the API key and writes <KEY>_ALLOWED_USERS with exactly the credential holder. Place: installer.py, __main__.py, hermes_config.py, 09 17.5, 17.7, 10 10.1. 17.8 and 10 10.1: a restart can hit several profiles, so the operator decides it; the product never writes a credential.
D-M2-33-13 2026-10-24 The adapter connects to the server only after Hermes finished its startup restore (the gateway runner's restore flag). The plugin entry the installer writes imports bothy_hermes_adapter.entry, which imports neither Hermes nor the SDK; platform.py is imported in the adapter factory, and a hook reaches the platform only when that module is loaded. Place: platform.py, entry.py, installer.py. During the restore Hermes queues every inbound message and reports it processed at once; a run started then would end without a turn. Hermes loads plugins in a background thread while its main thread imports the gateway and waits only a bounded time for that discovery: a plugin that imports gateway modules at load time raced those imports (Hermes exited with a KeyError from the import system), and a slow load started Hermes without the platform, both seen on a loaded CI runner.
D-M2-33-14 2026-10-24 The Fake-Hermes harness adds attrs and coverage at the locked versions and a .pth file with the two source trees to the provisioned environment, takes the integrity snapshots after that, gives every Hermes process an empty managed scope (HERMES_MANAGED_DIR), byte-compiles the export once, starts the gateway again when Hermes reports that it started without any platform or exits before the adapter identifies (at most _START_ATTEMPTS starts), runs the FakeServer with a wall clock, and moves the subprocess's coverage data next to the main data file. Place: tests/fake_hermes_kit.py. A machine-wide /etc/hermes must not reach the throwaway profile; Hermes waits a fixed time for its own plugin discovery and starts without the plugin's platform when a loaded machine needs longer (seen twice on a loaded machine), and its start once ended in an import error inside its own modules on a loaded machine, which are conditions of Hermes, not of the adapter; tokens and DPoP proofs must fit the subprocess's real clock; platform.py and hermes_config.py count toward the threshold.