AGENTS.md 12 KB

Agent Guidelines for hermes-chatto-plugin

This document stays contradiction-free

AGENTS.md is a rulebook — every rule in it must be readable without having to guess which one wins.

If two passages turn out to conflict, or a rule admits two readings with different outcomes, do not quietly choose one: point out the contradiction where you hit it, name both passages, and resolve it here in the text so the next reader cannot stumble into the same fork.

What this plugin is

This plugin exists to connect a Hermes Agent to a Chatto server. All Chatto API interaction goes through chattolib (vendored under vendor/) — never hand-roll ConnectRPC/WebSocket calls the library already covers. When behaviour is unclear on our side, the Chatto server source is the reference:

git clone https://github.com/chattocorp/chatto.git

The vendored copy corresponds to upstream chattolib[realtime], the 0.4.x line (realtime protocol v1) — Chatto servers older than v0.4.19 are not supported. Respect that floor when refreshing vendor/ via vendor_chattolib.sh (see VENDORING.md).

README.md is the human README

Two audiences: AGENTS.md is read by agents with this source tree open; README.md is read by humans who have not. That makes README.md the document where ambiguity is not allowed.

Everything a user meets first lives there — what the plugin is, how it behaves, how to configure it. Unambiguous in practice means:

  • Say which Chatto you mean. Server, chattolib and plugin all answer to "Chatto"; write the one you mean. (A capability-matrix column labelled just "Chatto" recently had to be asked whether it meant the plugin.)
  • One sentence, one reading. If a claim can be parsed two ways, it is false in at least one of them — rewrite until only one parse remains.
  • No shorthand that needs the source. Config keys, env vars and RPC names appear spelled out, never as "see adapter.py".
  • Keep versions current. Compatibility claims must match reality — chattolib tracks Chatto upstream, so every vendor/ refresh can move the supported server floor, and README.md has to move with it. ("Tested against Chatto v0.4.19" outlived its truth the moment the vendored copy became 0.4.20.)

Behavioural contract

These behaviours are promises to users, documented in README.md — adapter changes must keep them, or consciously renegotiate the docs.

  • Mention gating covers channels only. With require_mention enabled, channel-kind rooms answer only messages @-mentioning the bot; group-kind rooms and DMs answer regardless. In channels with the gate off, a message aimed at someone else gets a 🫥 acknowledgement instead of an answer.
  • Threads stay threads. An inbound thread reply keeps its thread context; a fresh room reply opens a thread under the incoming message unless auto_thread is disabled or the room is a DM.
  • Processing reactions. 👀 while working, then ✅ or ❌ — driven by on_processing_start/on_processing_complete and the reactions setting, never applied to the bot's own events.
  • Length handling. send() splits at 9900 chars against the 10000-char server limit; edit_message() refuses over-long content so callers fall back to send() rather than receiving silent truncation.

Auth defaults to closed

Without CHATTO_ALLOWED_USERS and without CHATTO_ALLOW_ALL_USERS=true, _check_auth denies everyone. Keep that fail-closed stance when touching the auth path. hermes_validate_config rejects setting both options at once on purpose, and allow_all_users hands every Chatto user full agent access including tool use.

Development

Work on this plugin happens against a real Hermes Agent checkout — set that up first, everything else builds on it:

  1. Fetch the Hermes Agent:
   git clone https://github.com/NousResearch/hermes-agent.git
  1. Put this plugin at hermes-agent/plugins/platforms/hermes-chatto-plugin. Either clone this repo straight into that path, or link an existing checkout so you can keep working in your own copy:
   ln -s /path/to/hermes-chatto-plugin \
     hermes-agent/plugins/platforms/hermes-chatto-plugin

Tests

adapter.py imports from gateway.*, which lives in the Hermes Agent rather than this repo. Without its source on the PYTHONPATH, collection dies with ModuleNotFoundError: No module named 'gateway' before a single test runs. With the layout above:

PYTHONPATH=/path/to/hermes-agent uv run --with pyyaml pytest -q

gateway.config needs pyyaml, which is not among our dev dependencies.

Readability

Code is written for humans first and machines second — follow Clean Code principles, with PEP 8 as the baseline.

Everything in this repo gets read far more often than it gets written: by maintainers revisiting it months later, and by agents working from these guidelines. Optimising for that reader is not optional polish. Concretely:

  • PEP 8 for naming, layout and structure — Ruff enforces most of it mechanically, but the rules below are judgement calls no linter makes for you.
  • Small functions with one job. If a function needs paragraphs of # commentary to explain its flow, split it until the code explains itself.
  • Names that say what they mean. _seed_room beats do_init; a boolean reads as a question (is_seen, retryable). No single-letter names outside throwaway loop variables.
  • Comments explain why, not what. The code already says what happens; the comment earns its line by recording intent, constraints, or the bug a guard exists to prevent (see adapter.py for the house style).
  • Explicit over clever. Idiomatic Python beats micro-optimised trickery — comprehension over map/filter chains, dataclasses over dict-shaped blobs, early returns over nested conditionals.
  • Docstrings on every public method, following PEP 257 — plus the override marker required by Overriding below.

Linting & Type Checking

Run Ruff on everything you touch, and leave no Pylance findings behind.

Ruff is the linter and formatter for this repo:

ruff check .          # lint
ruff format .         # format

Fix what ruff check reports instead of adding # noqa — if a rule genuinely does not fit here, that is a change to make deliberately, not inline.

Pylance (VS Code's Python language server) is the second gate. Its errors and warnings are expected to be clean on files you edit; treat new squiggles as unfinished work. Do not silence them with cast(Any, ...) or # type: ignore — see Typed access below for why that trade hides real bugs.

Configuration surface

One field, three coordinated names — declared once on ChattoConfiguration.

A config value lives as a ConfigField class attribute in platform_config.py. From the attribute name it derives the config.yaml key under gateway.platforms.chatto.extra (the config_key, overridable per field) and the CHATTO_* environment variable (upper-cased config_key). Resolution order per instance: env var → extra → default. Declaring a field is enough for the adapter, the env-enablement seeding and the setup wizard to pick it up; plugin.yaml additionally repeats the env names as wizard prompts, so keep those entries in step.

When adding or renaming a field, also update the environment-variable table in README.md — and document each option's default next to it, for env vars (Default column) and config.yaml keys alike. A user reading only the configuration docs must be able to predict what happens when they set nothing. Secrets (CHATTO_TOKEN, CHATTO_PASSWORD) belong in ~/.hermes/.env, never in config.yaml — docs and setup wizard rely on that split.

Typed access

Work against the declared types. Do not reach for values dynamically when a typed attribute exists.

chattolib ships dataclasses with py.typed for everything it returns — Message, Asset, AssetUpload, Room, User. Read their fields directly, so a wrong name is a loud AttributeError at the first call and a type checker can see it before that:

upload_id = upload.upload_id            # yes
upload_id = str(getattr(cast(Any, upload), "id", ""))   # no

The second form cost us every single file upload. AssetUpload has no id field — it is called upload_id — but cast(Any, ...) disables the check and the "" default turns the mismatch into an empty string. The result was an error message blaming the server (CreateUpload returned no upload ID) for a name we got wrong ourselves, and it survived in production because it never raised.

So: no cast(Any, ...) on a value whose type is known, and no getattr with a fallback default to paper over a field you are not sure about — look the field up in vendor/common/chattolib/types.py instead. getattr is fine for genuinely optional or duck-typed things (a handler that may not be set, a payload from an untyped source), not for dodging a type.

Tests should exercise real library types too, not mocks shaped like them. The upload bug was invisible because the tests replaced _upload_asset wholesale with an AsyncMock, so no test ever touched a real AssetUpload.

Overriding

Every method that overrides a base-class method must say so in its docstring, closing with the marker line BasePlatformAdapter override — the pattern already used throughout adapter.py.

This plugin lives by subclassing (ChattoAdapter overrides BasePlatformAdapter methods throughout), so an override is not an implementation detail — it is the contract between plugin and platform. Follow the existing form: first line says what the method does, the body spells out what changes about the inherited behaviour, and the last line names the parent class:

async def edit_message(self, chat_id, message_id, content, *, finalize=False):
    """Edit a message we previously sent, via MessageService/UpdateMessage.

    Without this override the base class reports "Not supported" and every
    incremental streaming update arrives as a *new* message. Content beyond
    the per-message limit is refused so callers fall back to ``send()``.

    BasePlatformAdapter override
    """

Capabilities

When you extend what the adapter can do, keep the startup banner current.

_capabilities() in adapter.py logs what the plugin supports at registration, so starting it up tells you what is available instead of making you read the source. Whether a capability counts is derived from real overrides: it only counts when ChattoAdapter replaces the BasePlatformAdapter method. The label for it, however, lives in _CAPABILITY_LABELS and is not discovered automatically.

So when you add an overridden BasePlatformAdapter method:

  1. Add an entry to _CAPABILITY_LABELS (method name → wording that means something to someone who has not read the code). Skip this and the plugin can do the thing but says so nowhere.
  2. Check whether the platform_hint in register() needs to mention it. That hint goes into the system prompt and is the only way the model learns what the channel can do — without it the agent falls back to shelling out.
  3. Add a test in TestRegistration if the capability is user-visible.
  4. Update the capability matrix and feature notes in README.md — that is the public version of the same list.

The reverse holds too: drop a method and its line disappears from the log on its own, but its _CAPABILITY_LABELS entry should go with it, along with its row in the user docs.

Shell Scripts

Always run shellcheck after editing any shell scripts.

shellcheck path/to/script.sh

Or validate all shell scripts in the project:

find . -name "*.sh" -exec shellcheck {} \;

Configuration

Project-specific shellcheck rules are defined in .shellcheckrc. Default severity is error to catch all issues.

Why

  • Prevents syntax errors and common pitfalls (e.g., missing quotes, unsafe variable expansions)
  • Ensures portability across different shell environments
  • Maintains code quality and security standards

Integration

Consider adding a pre-commit hook for automatic validation:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/koalaman/shellcheck-precommit
    rev: v0.9.0
    hooks:
      - id: shellcheck
        args: [--severity=error]