# Concepts How the plugin works where the reasoning is not obvious from the code. The README describes what the plugin does; `AGENTS.md` states the rules a change has to honour. This file explains *why* a few of those rules look the way they do, for a reader who wants to understand the design before changing it. - [Thread sessions and the frontier](#thread-sessions-and-the-frontier) - [Configuration resolution](#configuration-resolution) - [One send path, two callers](#one-send-path-two-callers) ## Thread sessions and the frontier ### The problem A Chatto thread gets its own Hermes session, and **that session starts empty**. Meanwhile two kinds of message never become a turn of their own: - **The bot's own messages.** Chatto echoes a posted message back over the realtime connection, and the adapter drops it as a self-event — correctly, since the gateway is already running the turn that produced it. - **Anything the gateway did not dispatch**, chiefly a message that arrived while it was down. README documents that there is no backfill. So a thread can contain messages that no session holds. The reported case: a cron job posts a build report into a channel, its echo is filtered, and an hour later someone replies to it. The thread it roots is new, its session is empty, and the agent would answer a message it cannot read. The fix is to hand the thread's earlier messages to the session — but only the ones it has not seen. Re-delivering the whole thread on every turn would fill the context with a conversation the session already holds. ### The frontier That "only what it has not seen" boundary is a **cursor**: one opaque message ID per thread, meaning *the session holds this thread's history up to here*. Two facts about it drive everything else: 1. **It has to advance past the turn's own trigger**, not just past the block that was delivered. The trigger is the message text of the turn, so the session holds it; a cursor left behind it hands the next turn the previous question a second time, as an `[Earlier in this thread]` line beside a conversation that already contains it. 2. **It cannot point past the fetched window.** The fetch is capped at `THREAD_CONTEXT_MESSAGE_LIMIT`, and a cursor the next fetch does not contain cannot be found again. When the trigger is missing from the window, the cursor records what was actually delivered instead. ### Why the frontier is stored, not derived The cursor lives twice: in `_seeded_threads` for the running process, and on the session (`chatto.thread_seed_cursor`) so it survives a restart. An earlier version of this code did not persist it, on the grounds that `set_session_metadata` rewrites the whole routing index per call. Instead it rebuilt the partition from the fetch the turn makes anyway: > the newest message that is not ours is the last one the session certainly > holds, because every other person's message arrives as its own turn That premise is false, and the fetch it reasons over is the shape a thread reply *always* produces: the window ends with the turn's own trigger — a person's message that became this turn seconds ago. So the rebuilt partition lands on the trigger, the tail comes out empty, and every bot message before it is skipped. After a restart the agent stops seeing its own output in a thread, which is the one thing the whole feature exists to prevent. Excluding the trigger does not rescue it. The *previous* turn's trigger is just as much a person's message that became a turn, so the next restart skips again. A session's frontier is not a function of the message list at all: every candidate message is either a turn the session holds (person) or one it does not (bot), and the list alone cannot tell a past trigger from a bot message — only the stored frontier can. ### When the write happens Persisting costs a full routing-index rewrite, so the write is spent only where it buys something — which is not every turn: - **A turn that delivered the bot's own output.** That is the one thing no later turn can recover from the message list, so it is the one cursor that has to outlive the process. A turn that carried only the trigger writes nothing: its frontier *is* "the newest message that is not ours", which a restart can work out for itself. - **The turn after a first turn whose cursor found no session to land on.** A thread's first turn has no session yet, so the write is refused; that refusal is remembered in `_unpersisted_threads` and retried, because a cursor that never lands is exactly the cursor a restart needs. ### The blind pass One case has no cursor anywhere. A thread's first turn seeded *nothing* — a reply that opens a fresh thread has no earlier messages — so there was nothing to write; there was no session to write it to either; and the process restarted before the second turn. The thread is blind exactly once. The blind pass re-delivers the bot's own messages, leaves people's messages to their own turns, and records the frontier so no later process is guessing. It can duplicate one block, in the rarer variant where that first seed *did* carry own output. Losing the output instead is the common case and permanent, so the bounded duplicate is the cheaper error. ### What the cursor is looked up with The adapter derives session keys of its own — to find the cursor, to find the turn an edit should cancel — and a derived key is worthless unless it is byte-identical to the one the gateway built. So it delegates to `_source_session_key` instead of calling `build_session_key` itself. That matters more than it looks: ingress runs *before* the runner stamps the profile onto the event, so a key built without the profile namespace puts every bot of a multiplexed gateway into `agent:main:` and names a session nobody is running. ### What this costs - One extra RPC per turn that delivers something: `get_thread_events`, capped at 20 messages. Quiet threads skip it — the cursor means there is nothing to fetch. - One session-metadata write per turn that delivered own output, plus one retry after a thread's first turn. Not per turn. - `[Earlier in this thread]` is reference text, not trusted input: only the trigger passes the auth gate. ## Configuration resolution Every setting is one `ConfigField` on `ChattoConfiguration`, which derives three coordinated names: the `config.yaml` key, the `CHATTO_*` environment variable, and the attribute the adapter reads. Values resolve env var → `config.yaml` extra → default, and every path parses the raw value by the field's kind first — a `.env` line is a string whatever the field means, and `"false"` is true if nobody parses it. The non-obvious part is **which configuration a tool call sees**. The gateway multiplexes several serving profiles through one process, so a process-wide config cache would hand one profile another profile's credentials. Cached configurations are therefore keyed by the profile scope Hermes itself uses, and secrets come from the scope the gateway installed rather than from `os.environ` — under multiplexing the environment holds the *launch* profile's values. One consequence worth knowing: a configuration that resolved without a token is not cached at all. Caching it would pin "token not configured" for the life of the process, so a later call in a properly scoped context would keep being served the degraded configuration long after its scope came back. ## One send path, two callers A user cannot tell whether the agent replied on its own or through a tool, so the two paths must not behave differently. Both go through `_deliver_chunks`, which owns the split at the 9900-character limit, the routing of a split answer as one answer (the first chunk opens or joins a thread, later chunks carry that root), and the stop-at-first-failure. The callers differ only in what they do with the result: `send()` shapes it into a `SendResult`, `_post_chunks` into a refusal that names which chunk failed and lists the IDs that went out — so a retry repeats only what is missing instead of duplicating a message the room already has.