Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
# Architecture Decision Records
## 2026-07-13: Scope plugin manager state by Hermes home/profile (keyed cache)
Status: Accepted
Context:
Hermes supports multiple profiles via different Hermes home directories.
Homes are switched two ways in a running process: the `HERMES_HOME`
environment variable (single-profile CLI/gateway processes), and the
context-local `set_hermes_home_override()` (`hermes_constants.py`), which
the multiplexed gateway worker (`gateway/run.py`'s `_profile_scope`) and
subagent/embedded callers use to serve several profiles from one
long-lived process. The override is a `ContextVar` and deliberately does
**not** mutate `os.environ`, since that would leak one profile's home
into every other concurrent task in the same process.
The plugin manager was a process-global single-slot singleton
(`_plugin_manager`). User-installed plugins are discovered from
`get_hermes_home() / "plugins"`, and context-engine plugins (e.g.
`hermes-lcm`) capture profile-scoped state — such as the LCM database
path — at registration time. A single-slot cache meant:
1. Switching homes via `set_hermes_home_override()` was invisible to a
naive "did `HERMES_HOME` change" check, so the singleton silently kept
serving the first profile's manager to every other profile in the
process.
2. Even when a fresh `PluginManager` *was* created for a new home, plugin
modules are imported into `sys.modules` as `hermes_plugins.<slug>` by
`_load_directory_module`, and only that top-level module was ever
replaced. A same-slug plugin's *relative* imports
(`from . import state`) are cached separately under
`hermes_plugins.<slug>.<submodule>`, and Python's import machinery
resolves those from `sys.modules` first — so a profile switch could
silently keep serving a previous profile's already-imported submodule
code/state instead of re-executing the new profile's plugin.
Decision:
- Replace the single-slot singleton with a cache keyed on the *resolved*
Hermes home path (`_plugin_managers_by_home: Dict[Path, PluginManager]`).
`get_plugin_manager()` resolves the current home via `get_hermes_home()`
(which itself already consults `get_hermes_home_override()` before
`os.environ`), so both the env-var and context-local override paths are
covered uniformly.
- `_plugin_manager` (the old single-slot name) is kept as a thin "last
manager returned" pointer purely for backward compatibility with
existing test code that does
`monkeypatch.setattr(plugins_mod, "_plugin_manager", some_manager)`.
When that name is monkeypatched to a manager the keyed cache doesn't
know about, `get_plugin_manager()` treats it as an explicit injection
and adopts it into the cache under the *current* resolved home, rather
than discarding it.
- Both `PluginManager._load_directory_module` (initial/`force=True`
reload within the same home) and the shared `_clear_plugin_submodules`
helper (profile switch / test teardown) evict `sys.modules[module_name]`
**and every name prefixed with `module_name + "."`** before a plugin
slug is (re-)imported, so relative-import submodules can never survive
a reload or a home switch.
- Test isolation (`tests/conftest.py`'s `_hermetic_environment` fixture)
calls a new `_reset_plugin_managers_for_tests()` helper that drops the
entire keyed cache and purges every plugin submodule from `sys.modules`
between tests, instead of only resetting the single-slot pointer.
Consequences:
- Per-profile LCM instances (and any other context-engine plugin) use
their own `{home}/lcm.db` regardless of whether the profile switch went
through `HERMES_HOME` or `set_hermes_home_override()`.
- Plugin discovery remains cached within a profile for normal
performance, and re-entering a previously-seen profile reuses its
cached manager instead of rebuilding from scratch.
- Sequential *and* interleaved profile switching — in tests, the gateway
multiplexer worker, or embedded callers using the context-local
override — no longer leaks context-engine state, plugin module state,
or stale relative-import submodules across profiles.
- Regression coverage exercises the real production path
(`set_hermes_home_override()`) rather than only the env-var path, and
includes a dedicated relative-import leak test.
+179
View File
@@ -0,0 +1,179 @@
# Billing lifecycle: client-side state, errors, and recovery
This is the map from every `billing.*`/`subscription.*` state shape the gateway
serves (from NAS) to what the terminal actually renders, and from every typed
refusal/error code to its exact user-facing copy and recovery action. The
guarantee: no NAS billing state and no typed refusal falls through to a
generic toast — every case below is an explicit branch in
`ui-tui/src/app/slash/commands/topup.ts`, `ui-tui/src/components/billingOverlay.tsx`,
or `ui-tui/src/components/subscriptionOverlay.tsx`. An **unknown** code still
degrades gracefully: it hits the `default` branch (a generic-but-real message
pulled from the server payload, never a blank toast) rather than crashing or
silently dropping the refusal.
## 1. `billing.state` shapes → render
Source: `ui-tui/src/components/billingOverlay.tsx` (`OverviewScreen`,
`BuyScreen`, `AutoReloadScreen`), `ui-tui/src/app/slash/commands/topup.ts` (`/topup` run).
| State shape | Render |
|---|---|
| Logged out (`s.logged_in === false`) | Overlay never opens. `sys`: `💳 Not logged into Nous Portal — run /portal to log in, then /topup.` |
| `billing.state` RPC fetch fails (transport/timeout) | **Fail-closed**: `.catch(ctx.guardedErr)` — overlay never opens, no state is assumed. `sys`: `error: <message or "request failed">`. Never renders "no card" or any other guessed state; user must retry `/topup`. |
| `card: null` (no saved card), full menu (`is_admin && cli_billing_enabled`) | Overview shows `No saved card on file — "Add funds" walks you through adding one.` "Add funds" opens the **add-card path**: `Add a card on the portal` / `I've added it — check again` / `Back` (never an amount picker, which would 403 `no_payment_method`). |
| `card` present, `resolved_via` set | `Card: {display}` (e.g. `Visa ····4242 — the card on your subscription`) using the provenance-aware `display` field. |
| `card` present, `resolved_via` absent (older NAS) | Falls back to the generic `Card: {masked}`; Confirm screen adds `Your card saved on the portal will be charged.` |
| `auto_reload: null` | No auto-reload line at all (`autoReloadLine` returns `null`) — the feature isn't surfaced. |
| `auto_reload.card.kind: 'canonical'` | No distinct-card warning; card line falls back to the card on file. |
| `auto_reload.card.kind: 'distinct'` | `⚠ Auto-refill is charging {brand} ••{last4} — not your card on file.` in the Auto-reload screen (the divergence notice). |
| `auto_reload.card.kind: 'none'` | Same as `canonical` rendering-wise — no distinct-card warning shown. |
| `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). |
| `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` |
| Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. |
| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page.` Same collapsed menu. |
Note: `full = s.is_admin && s.cli_billing_enabled` gates the **org-level**
switch, not the per-terminal `billing:manage` scope — that's discovered
reactively (a charge 403s `insufficient_scope`) and routes to the resumable
step-up screen instead of a preflight check.
## 2. Refusal codes (`renderBillingError`, in code order)
Source: `renderBillingError` in `ui-tui/src/app/slash/commands/topup.ts:37-149`.
"Portal" row = `sys('Portal: {portal_url}')` is appended whenever `portal_url` is present, for every code (including default).
| `error` code | Copy | Portal URL | `retry_after` |
|---|---|:-:|:-:|
| `insufficient_scope` | `This needs Remote Spending allowed. Start a top-up to allow it, then retry.` | if present | — |
| `remote_spending_revoked` (CF-4) | `{An admin stopped remote spending for this terminal. \| You stopped remote spending for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — |
| `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — |
| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.` | if present | — |
| `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — |
| `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — |
| `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — |
| `upgrade_cap_exceeded` | `🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.` | if present | — |
| `auto_top_up_disabled_failures` | `Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.` | if present | — |
| `idempotency_conflict` | `🔴 That charge key was already used for a different amount. Start a fresh top-up.` | if present | — |
| `no_payment_method` | `💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card).` | if present | — |
| `monthly_cap_exceeded` | `🔴 Monthly spend cap reached — ${remainingUsd} headroom left.` if `payload.remainingUsd` present, else `🔴 Monthly spend cap reached.` | if present | — |
| `rate_limited` / `temporarily_unavailable` | `🟡 Too many charges right now{ (try again in ~N min)}. This isn't a payment failure.` | if present | **yes** — minutes computed as `max(1, round(retry_after/60))` |
| `stripe_unavailable` | `🟡 Stripe is having trouble right now — try again shortly{ (try again in ~N min)}.` | if present | **yes** (same formula) |
| *default (unknown/other)* | `🔴 {message \|\| error \|\| 'Billing request failed.'}` — still surfaces whatever the server said, never a blank toast. | if present | — |
## 3. Charge settlement outcomes (`pollCharge` / `renderChargeFailed`)
Source: `pollCharge` (`ui-tui/src/app/slash/commands/topup.ts:170-258`) and
`renderChargeFailed` (`:260-290`). Poll cadence: 2s interval, 5-minute cap
(`POLL_INTERVAL_MS=2000`, `POLL_CAP_MS=5*60*1000`), applied on **every**
non-terminal path (pending *and* throttled), so a sustained 429/503 can't
keep the poll alive forever.
| Outcome | Copy | Notes |
|---|---|---|
| `status: 'settled'` | `✅ ${amount_usd} added.` (or `✅ Credits added.` if no amount) | Terminal success. |
| `status: 'failed'`, `reason: 'authentication_required'` | `🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase.` | + `Portal:` line if `portalUrl`. |
| `status: 'failed'`, `reason: 'payment_method_expired'` | `🔴 Your card has expired. Update it on the portal.` | + `Portal:` line. |
| `status: 'failed'`, `reason: 'card_declined'` | `🔴 Your card was declined. Try another card on the portal.` | + `Portal:` line. |
| `status: 'failed'`, `reason: 'processing_error'` | `🔴 The charge didn't go through (processing_error).` | + `Portal:` line. |
| `status: 'failed'`, unrecognized/missing `reason` | `🔴 The charge didn't go through ({reason \|\| 'processing_error'}).` | Same portal funnel — parity with `cli.py`'s `_billing_portal_hint`. |
| Poll timeout (still `pending` past the 5-min cap) | `🟡 Still processing after 5 minutes — this is a timeout, not a failure. Check /topup or the portal shortly.` | + `Portal:` line if `portalUrl`. Explicitly NOT called a failure. |
| Revocation mid-poll (`remote_spending_revoked` / `session_revoked` while polling) | Renders the matching §2 copy, **then** appends: `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` | CF-7 rule 4: a post-revoke 403 while polling is ambiguous (the charge may have already settled) — never call it "failed". |
| 429/503 while polling (`rate_limited`/`temporarily_unavailable`/`stripe_unavailable`) | No error shown; backs off using `retry_after` (default 5s, capped at 30s) and keeps polling until the 5-min cap, then reads as timeout. | Not a payment failure. |
| Other `!ok` status-check error | `🔴 Could not check the charge: {message \|\| error \|\| 'error'}` | |
| Transport loss (poll RPC throws/rejects) | `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` (`UNCONFIRMED_CHARGE_MESSAGE`) | Same "unconfirmed, check balance" framing as revocation mid-poll — a dropped connection can never be read as "failed". |
## 4. Subscription preview / pending-change / upgrade outcomes
Source: `previewAndRoute`, `applyPendingAndRoute`, `upgradeResult`,
`stepUpDenialResult` in `ui-tui/src/components/subscriptionOverlay.tsx`.
**Preview `effect` values** (drive the Confirm screen):
| `effect` | Confirm screen copy | Primary action |
|---|---|---|
| `charge_now` | `Upgrade to {target}. You will be charged {amount} now (prorated).` (+ monthly-credits delta, + which card if resolver confidently knows) | `Pay {amount} & upgrade now` |
| `scheduled` | `Change to {target} — takes effect {date}. No charge now; you keep your current plan until then.` | `Schedule change to {target}` |
| `no_op` | `You are already on {target} — nothing to change.` | none (Back only) |
| `blocked` | `{preview.reason}` or fallback `That change cannot be made here — manage it on the portal.` | `Manage on portal` |
| Preview RPC returns `null`/transport failure | routes straight to Result: `Could not preview that change.` | — |
| Preview `!ok`, `insufficient_scope` | routes to `stepup` screen (`{kind:'preview', tierId}`) | — |
| Preview `!ok`, other error | routes to Result with `errorResult(p)` (`message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'`) | — |
**Pending-change apply outcomes** (`applyPendingAndRoute`):
| `pending.kind` | Success copy |
|---|---|
| `cancellation` | `Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.` |
| `tier_change` (downgrade/schedule) | `Scheduled — your plan doesn't change today. You keep your current plan until the end of the billing period, then it switches.` |
| `upgrade` | routed through `upgradeResult` (below) |
| any kind, mutation `insufficient_scope` | routes to stepup (`{kind:'apply'}`) |
**Upgrade `status` × `reason` matrix** (`upgradeResult`, checked in this
order — `reason` is checked *before* `status`):
| Condition | Result |
|---|---|
| `r === null` (transport failure on the charging route) | `Couldn't confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.` — ambiguous, never a blind retry. |
| `reason: 'authentication_required'` **or** `reason: 'subscription_payment_intent_requires_action'` | `Please verify your card in the portal to finish this upgrade.``recovery_url`. **Both reasons map to the same SCA copy** — the client branches on `reason`, not `status`, specifically so an SCA case that pre-#711 NAS mislabels with `status: 'payment_failed'` (no distinguishing reason yet) still routes to the correct "verify your card" copy instead of reading as a hard decline. |
| `reason: 'card_declined'` | `Your card was declined — try a different card on the portal.``recovery_url`. |
| `ok && status: 'already_on_tier'` | `You are already on {target_tier_name}.` (success) |
| `ok && status: 'upgraded'` | `Upgraded to {target_tier_name}. Your new monthly credits land in a moment.` — starts the eventual-consistency apply-poll (below). |
| `status: 'requires_action'` (no distinguishing reason) | `This upgrade needs extra verification (3DS). Finish it on the portal.``recovery_url`. |
| `status: 'payment_failed'` (no distinguishing reason) | `Your card was declined. Update your payment method on the portal and try again.``recovery_url`. |
| anything else | `errorResult(r)`: `message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'` |
**Eventual-consistency apply-poll** (`ResultScreen`, only after `status:
'upgraded'`): polls `billing`/subscription state every 2s
(`UPGRADE_CONFIRM_INTERVAL_MS`) up to 15 attempts
(`UPGRADE_CONFIRM_ATTEMPTS`, i.e. ~30s) until `current.tier_id` flips to the
target. While waiting the screen reads `Applying…`; if it never flips inside
the budget it reads `Still applying` / `Your upgrade succeeded and is still
applying — refresh in a moment.` — the upgrade is never re-reported as failed
just because NAS hasn't caught up yet.
**Step-up denial copy** (`stepUpDenialResult`, subscription flow):
| `error` | Copy |
|---|---|
| `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` |
| `remote_spending_revoked` | `{message}` or `Remote spending was stopped for this terminal — reconnect from the portal, then retry.` |
| `rate_limited` | `Too many attempts — wait a moment, then try again.` |
| other/unknown | `{message}` or `Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal.` |
A **repeat** scope denial during a post-grant replay never re-enters the
step-up screen (it's already mounted there — re-patching would freeze it);
`allowStepUp=false` instead surfaces a terminal result: `Remote Spending still
isnt active for this terminal — the authorization didnt take. Retry, or make
this change on the portal.`
## Text-mode (CLI) parity
`cli.py`'s `_show_billing` / `_billing_overview` and `_show_subscription` /
`_subscription_overview` render the same state shapes (balance title, two-bar
dollar usage, auto-reload line, card line, monthly cap) and share the
"fail-open on logged-out/portal-hiccup, never crash" discipline. The CLI's
`/subscription` gives a paid admin/owner in an interactive context the **full
in-terminal change flow** (tier picker → preview → confirm → apply, parity
with the TUI overlay); members and non-interactive contexts fall back to
`_billing_portal_hint`'s deep-link to `subscription_manage_url`. `/topup`'s
interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and
non-interactive contexts fall back to the same text + portal-link rendering,
never prompting.
| CLI surface / state | Behavior (parity with TUI / desktop) |
| --- | --- |
| `/subscription` on **Free** + admin/owner + interactive | `_subscription_free_catalog` prints the plan catalog from the same `tiers[]` data the TUI uses — one row per enabled paid tier, cheapest first, `name · $/mo · $credits/mo` (monthly credits are DOLLARS → `$22 credits/mo`, never a bare number). A numbered pick opens the `/manage-subscription` deep-link with `plan=<tier_id>` appended so the portal preselects the chosen plan. Starting a new subscription needs a fresh card, so the only action is the portal hand-off (the terminal never charges here). |
| Any CLI-built manage/subscribe URL | `subscription_manage_url(state, tier_id=…)` appends `plan=<tier_id>` (the stable `tiers[]` id, never a name/slug) **only when a tier was picked** (the Free catalog). The portal validates it server-side and ignores an unknown tier, so the CLI appends unconditionally on a pick, mirroring the TUI's `?plan=`. `org_id` is emitted first, `plan` second. |
| **Downgrades** in the CLI | Stay **native / in-app** for normal changes (chargeless scheduling via `put_subscription_pending_change`). A blocked downgrade may still print the generic manage URL, but it never carries `plan=<tier_id>` — selected-tier deep-links are reserved for new subscriptions and upgrades. |
| `/topup` overview action copy | Splits one-time top-up from automatic refill, the distinction stated up front in each first sentence: `Add funds now — a single charge, added to your balance today.` vs `Refill when low — charges $X automatically when your balance falls below $Y.` ("credits" stays out of the dollars-only `/topup` surface — "Add funds now" carries the one-time meaning without it). When auto-reload is off, the automatic line omits concrete amounts. |
## Forward compatibility
Any `error`/`status`/`reason` code not in the tables above lands on the
`default` branch in `renderBillingError` (§2) or `errorResult`/`upgradeResult`'s
fallthrough (§4): it still renders the server's own `message` (never blank,
never a crash), just without bespoke copy or a typed recovery affordance.
NAS W3 introduces card-health codes (`card_paused`, `card_expired`,
`card_mismatch`) that are not yet typed here — until a client update adds
explicit branches, they will arrive as unknown codes and degrade to this
default path.
+222
View File
@@ -0,0 +1,222 @@
# Chronos managed-cron — agent ↔ NAS wire contract
**Status:** authoritative wire spec for the Chronos cron provider.
**Audience:** the NAS-side implementer of the `agent-cron` endpoints
(`nous-account-service`) and anyone debugging the managed-cron path.
Chronos lets a hosted Hermes gateway **scale to zero** while idle and still
fire cron jobs. Instead of an in-process 60-second ticker, the agent asks NAS
to arm exactly **one external one-shot per job at that job's real next-fire
time**. NAS calls the agent back at fire time over an authenticated webhook;
the agent runs the job and re-arms the next one-shot. Between fires the agent
process can be fully stopped — it wakes only on a genuine fire.
The external scheduler NAS uses to implement the one-shots is an **internal NAS
implementation detail**. The agent never talks to it, never holds its
credentials, and never names it. The agent only knows the three NAS endpoints
below.
```
create/update/pause/resume/remove a cron job (agent side)
ChronosCronScheduler.reconcile() ── agent computes next_run_at
│ POST {portal}/api/agent-cron/provision (auth: agent's Nous access token)
NAS arms a one-shot for fire_at ── NAS owns the scheduler + its creds
⏰ at fire_at
scheduler → POST {portal}/api/agent-cron/relay (auth: scheduler signature, NAS-verified)
NAS mints a short-lived agent-audience JWT (purpose=cron_fire)
│ POST {agent_callback_url}/api/cron/fire (auth: that JWT)
agent verifies the NAS JWT → store CAS claim → run_one_job → re-arm next one-shot
```
## Trust model (read this first)
| Hop | Who calls whom | Auth mechanism | Verified by |
|---|---|---|---|
| 1 | agent → NAS (`provision`/`cancel`/`list`) | the agent's existing **Nous Portal access token** (Bearer) — for a hosted agent this is the **bootstrap-session token** NAS planted in `auth.json` (client `hermes-cli-vps`), NOT an `agent:*` client token | NAS (its normal agent-token path) |
| 2 | scheduler → NAS (`relay`) | the scheduler's request **signature** | NAS (the signature path it already has) |
| 3 | NAS → agent (`/api/cron/fire`) | a **short-lived NAS-minted JWT** (`aud=agent:{instance_id}`, `purpose=cron_fire`) | agent (PyJWT against NAS JWKS) |
> **Which token, exactly (hop 1).** A hosted agent never holds an `agent:{instance_id}`
> OAuth client credential — that shape is minted only by the interactive dashboard
> auth-code grant (a browser user). For all of its own outbound portal calls the
> agent uses the **bootstrap-session access token** (`resolve_nous_access_token`),
> minted under the bootstrap-only client `hermes-cli-vps` and seeded into the
> container on first boot. NAS therefore must resolve the calling agent's instance
> id from EITHER an `agent:{id}` client (self-hosted/dashboard callers) OR — for the
> bootstrap token — from `AgentInstance.bootstrapSessionId` matching the token's
> session id (`sid`), org-scoped. The fire JWT minted at hop 3 still carries
> `aud=agent:{instance_id}` regardless. (Gating hop 1 on an `agent:*` client alone
> 403s every real hosted-agent provision — see `src/server/agent-cron/instance-auth.ts`.)
Why NAS-mediated rather than scheduler→agent direct: the scheduler signs with
**NAS's** keys, which the agent does not (and should not) hold. The agent can
only verify a **NAS-minted** token — a trust path it already has. This keeps
all scheduler credentials inside NAS. (Full rationale: the plan's DQ-4.)
No new secret is introduced on the agent: hop 1 reuses the token the agent
already uses for the portal, and hop 3 reuses the NAS-JWT verification the agent
already performs.
---
## Endpoint 1 — `POST /api/agent-cron/provision` (agent → NAS)
Arm (or re-arm, idempotently) exactly one one-shot for a job.
- **Auth:** `Authorization: Bearer <agent Nous access token>`. NAS validates via
its normal agent-token path and scopes the row to the calling agent/org.
- **Request body:**
```json
{
"job_id": "ab12cd34",
"fire_at": "2026-06-18T12:34:56+00:00",
"agent_callback_url": "https://agent-xyz.fly.dev",
"dedup_key": "ab12cd34:2026-06-18T12:34:56+00:00"
}
```
- `fire_at` — ISO 8601, **agent-computed**. May be sub-minute in the future;
NAS must honor second-granularity (the agent owns the time, so there is no
1-minute scheduler floor).
- `agent_callback_url` — the agent's own publicly-reachable base URL. NAS
POSTs `{agent_callback_url}/api/cron/fire` at fire time.
- `dedup_key` — `"{job_id}:{fire_at}"`. NAS **upserts by `(agent_id, job_id)`**
so re-arming the same fire is idempotent (no duplicate one-shots). A new
`fire_at` for the same `job_id` replaces the prior arm.
- **Action:** arm one one-shot to fire at `fire_at`, destined for the NAS
**relay** route (Endpoint 3) — NOT the agent directly, so NAS stays in the
loop to mint the agent JWT. Persist `(agent_id, job_id, schedule_id,
agent_callback_url)`.
- **Response:** `200 {"schedule_id": "<opaque>"}`.
## Endpoint 2 — `POST /api/agent-cron/cancel` (agent → NAS)
- **Auth:** same as Endpoint 1.
- **Body:** `{"job_id": "ab12cd34"}`.
- **Action:** cancel the armed one-shot for `(agent_id, job_id)` and delete the
row. Idempotent — cancelling an unknown job is a 200 no-op.
- **Response:** `200 {"ok": true}`.
## Endpoint 3 — `POST /api/agent-cron/relay` (scheduler → NAS, the fire relay)
- **Auth:** the scheduler's request **signature**, verified by NAS with the
signature path it already has. This is the trust boundary for the fire — a
forged relay call must be rejected here.
- **Action:**
1. Look up `(agent_id, job_id) → agent_callback_url` from the persisted row.
2. Mint a **short-lived** JWT: `aud = "agent:{instance_id}"`,
`iss = {portal_url}`, `purpose = "cron_fire"`, small `exp` (≈60120s),
signed with NAS's normal asymmetric signing key (published via JWKS).
3. `POST {agent_callback_url}/api/cron/fire` with
`Authorization: Bearer <that JWT>` and body `{"job_id": "...", "fire_at": "..."}`.
4. Treat a non-2xx agent response as a **retryable** failure (let the
scheduler retry the relay). The agent's store CAS de-dupes a double fire,
so retries are safe.
- **Response to the scheduler:** 2xx once the agent POST is accepted (202), so
the scheduler does not retry a delivered fire.
---
## Inbound `POST /api/cron/fire` (NAS → agent) — agent side, already implemented
This is the agent endpoint NAS calls in Endpoint 3 step 3. Two hops on hosted
deployments:
1. **Dashboard app** (`hermes_cli/web_server.py`) — the agent's only public
HTTP surface (the Fly proxy exposes exactly one port, the dashboard's). It
is in `PUBLIC_API_PATHS` so the dashboard cookie gate lets the bearer-JWT
callback through to the verifier. The dashboard verifies the JWT, resolves
the job's profile, then **forwards** the fire to hop 2 on loopback with the
NAS bearer preserved — it does NOT execute the job itself.
2. **Gateway `APIServerAdapter`** (`gateway/platforms/api_server.py`, loopback
bind, default port 8642) — re-verifies the JWT (defense in depth) and runs
the job with the gateway's **live platform adapters**, which is what makes
delivery work for relay-fronted logical platforms and E2EE rooms (the
standalone send path can serve neither). Self-host API-server deployments
that expose the api_server directly hit hop 2 without hop 1.
Gateway unreachable from hop 1 (scale-to-zero wake still booting, restart
window, api_server disabled) → the dashboard returns **503** and NAS retries
(non-2xx = retryable, below); the store CAS de-dupes the eventual double fire.
There is deliberately no in-dashboard execution fallback. The verifier is
`plugins/cron/chronos/verify.py`.
- **Auth:** `Authorization: Bearer <NAS-minted JWT>`. The agent verifies:
- signature against the NAS JWKS (`cron.chronos.nas_jwks_url`),
- `aud` == `cron.chronos.expected_audience` (this agent's
`agent:{instance_id}`),
- `iss` == `cron.chronos.portal_url`,
- `exp` / `nbf` (30s leeway),
- `purpose == "cron_fire"` — a general agent JWT (no/other purpose) is
rejected so it can't be replayed against this endpoint.
- **Body:** `{"job_id": "ab12cd34", "fire_at": "..."}` (only `job_id` is used).
- **Behavior:**
- invalid/missing/forged/expired/wrong-aud/wrong-purpose token → **401**, no
execution.
- missing `job_id` → **400**.
- valid → **202 `{"status": "accepted", "job_id": "..."}`** immediately, and
the job runs in the background. 202-before-run means a long agent turn never
trips the relay's HTTP timeout.
- **At-most-once:** the agent claims the job with a store-level compare-and-set
(`claim_job_for_fire`) before running. A relay/scheduler retry that arrives
while the first fire is in flight (or after it completed) loses the claim and
does not double-run.
---
## At-most-once & re-arm semantics
- **Recurring (cron/interval):** on fire, the agent advances `next_run_at`
(under its store lock) as part of the claim, runs the job, then re-provisions
a one-shot for the new `next_run_at`. A duplicate relay for the old `fire_at`
finds the claim taken / time advanced and is dropped.
- **One-shot (`30m`, `+90s`, etc.):** fires once; `mark_job_run` marks it
completed. No re-arm.
- **`repeat.times = N`:** `mark_job_run` deletes the job at the limit, so
`get_job` returns `None` after the final fire → the agent does **not** re-arm
→ the schedule stops cleanly with no orphaned one-shot.
- **Multi-replica agents:** the store CAS makes the fire at-most-once across N
gateway replicas sharing one `HERMES_HOME` — exactly one replica runs each
fire.
## Reconcile (self-healing)
The agent reconciles desired (`jobs.json`) vs armed on:
- `start()` (gateway boot / wake),
- every successful job mutation (`on_jobs_changed`),
- piggybacked after each fire (re-arm).
Reconcile arms missing/changed-time jobs and cancels orphans. A missed
provision (transient NAS error) self-heals on the next reconcile. There is **no
periodic wake** of a sleeping agent — that would negate scale-to-zero.
## Config (agent side)
All non-secret (`cron.chronos.*` in `config.yaml`); the agent holds no scheduler
credentials. For hosted agents NAS sets these at provision time:
| key | meaning |
|---|---|
| `cron.provider` | `"chronos"` to activate (empty = built-in ticker) |
| `cron.chronos.portal_url` | NAS base URL (also the expected JWT `iss`) |
| `cron.chronos.callback_url` | the agent's own public base URL for NAS→agent fires |
| `cron.chronos.expected_audience` | this agent's JWT `aud` (`agent:{instance_id}`) |
| `cron.chronos.nas_jwks_url` | NAS JWKS for verifying the fire JWT |
If `callback_url` / `portal_url` is blank or the agent has no Nous login,
`is_available()` returns False and the resolver falls back to the built-in
in-process ticker — cron never loses its trigger.
## Escape hatch (not default)
The inbound `/api/cron/fire` verifier is pluggable (`get_fire_verifier()`). If
relay volume through NAS ever saturates, a direct scheduler→agent mode with a
per-job NAS-minted cron-key can replace the NAS-JWT verifier with **no change to
the webhook handler**. NAS-mediated (this contract) is the default.
+32
View File
@@ -0,0 +1,32 @@
# Cron Doctor Spec
## Problem
Scheduled jobs can silently degrade when a script is moved, a workdir disappears,
a provider run fails, or delivery starts failing. `hermes cron list` shows some of
this inline, but there is no compact read-only health check that can be run from a
terminal, cron job, or CI-style smoke check.
## Goal
Add `hermes cron doctor` as a read-only diagnostic command that summarizes cron
job health and exits non-zero when actionable issues are found.
## Non-goals
- Do not mutate jobs or auto-repair state.
- Do not start/stop the gateway.
- Do not inspect secrets or print credentials.
## Acceptance criteria
- `hermes cron doctor` returns `0` and prints a healthy message when active jobs
have no detected issues.
- It returns `1` and prints grouped job-level issues when any active job has:
- last run failure (`last_status` not `ok`),
- last delivery failure,
- no `next_run_at` while still active,
- `no_agent` enabled without a script,
- script path missing/outside `HERMES_HOME/scripts`, or
- configured workdir path missing.
- Parser, command dispatch, and focused tests cover the new subcommand.
+903
View File
@@ -0,0 +1,903 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hermes Kanban — Native Dialog Prototypes</title>
<style>
/* =========================================================== */
/* Design tokens — host @nous-research/ui, oklch→sRGB approx. */
/* =========================================================== */
:root {
--bg: #ffffff;
--bg-subtle: #f8f9fa;
--bg-muted: #f1f3f5;
--border: #e5e7eb;
--border-strong: #d1d5db;
--fg: #0f172a;
--fg-muted: #64748b;
--fg-subtle: #94a3b8;
--ring: #94a3b8;
--accent: #2563eb;
--accent-fg: #ffffff;
--destructive: #dc2626;
--destructive-fg: #ffffff;
--success: #16a34a;
--shadow-md: 0 4px 12px -2px rgba(15, 23, 42, 0.10),
0 2px 4px -2px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 20px 25px -5px rgba(15, 23, 42, 0.10),
0 8px 10px -6px rgba(15, 23, 42, 0.04);
--radius: 10px;
--radius-sm: 6px;
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0b0d10;
--bg-subtle: #11141a;
--bg-muted: #161a22;
--border: #232833;
--border-strong: #2c3140;
--fg: #e6e8ec;
--fg-muted: #9aa3b2;
--fg-subtle: #6b7280;
--ring: #4b5563;
--accent: #3b82f6;
--accent-fg: #ffffff;
--destructive: #ef4444;
--destructive-fg: #ffffff;
--success: #22c55e;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font);
color: var(--fg);
background: var(--bg-subtle);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* =========================================================== */
/* Page chrome */
/* =========================================================== */
header.page {
padding: 24px 32px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
header.page h1 {
font-size: 18px; font-weight: 600; margin: 0 0 4px;
letter-spacing: -0.01em;
}
header.page p {
font-size: 13px; color: var(--fg-muted); margin: 0;
max-width: 920px;
}
header.page .controls {
margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap;
}
header.page .controls button {
font: inherit; font-size: 12px; font-weight: 500;
padding: 6px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg);
color: var(--fg); cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
header.page .controls button:hover { background: var(--bg-muted); }
header.page .controls button.active {
background: var(--accent); border-color: var(--accent); color: var(--accent-fg);
}
.board {
padding: 24px 32px 80px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
max-width: 1700px;
}
@media (max-width: 1400px) { .board { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .board { grid-template-columns: 1fr; } }
section.variant {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
display: flex; flex-direction: column;
min-width: 0;
}
section.variant header {
padding: 14px 16px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-subtle);
}
section.variant header h2 {
font-size: 11px; font-weight: 600; margin: 0 0 2px;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--fg-muted);
}
section.variant header .label {
font-size: 15px; font-weight: 600; color: var(--fg);
letter-spacing: -0.01em;
}
section.variant header p {
font-size: 12px; color: var(--fg-muted); margin: 6px 0 0;
line-height: 1.4;
}
section.variant .stage {
flex: 1;
padding: 24px 16px;
display: flex; flex-direction: column; align-items: center;
justify-content: flex-start;
background: var(--bg);
min-height: 540px;
position: relative;
}
section.variant .actions {
padding: 10px 16px;
border-top: 1px solid var(--border);
display: flex; flex-wrap: wrap; gap: 6px;
background: var(--bg-subtle);
}
section.variant .actions button {
font: inherit; font-size: 11px;
padding: 5px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg);
color: var(--fg-muted); cursor: pointer;
}
section.variant .actions button:hover {
border-color: var(--border-strong); color: var(--fg);
}
section.variant .actions .trash {
color: var(--destructive); border-color: var(--destructive);
background: color-mix(in srgb, var(--destructive) 6%, var(--bg));
}
section.variant .note {
font-size: 11px; color: var(--fg-muted);
padding: 8px 12px; background: var(--bg-muted);
border-top: 1px solid var(--border);
line-height: 1.45;
}
section.variant .note strong { color: var(--fg); }
section.variant .note code {
font-family: var(--mono); font-size: 10px;
background: var(--bg); padding: 1px 4px; border-radius: 3px;
border: 1px solid var(--border);
}
/* =========================================================== */
/* Backdrop + dialog (shared) */
/* =========================================================== */
.backdrop {
position: absolute; inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center;
z-index: 10;
animation: fade-in 160ms ease both;
padding: 16px;
}
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes dialog-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: none; }
}
.dialog {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 100%; max-width: 440px;
padding: 20px;
animation: dialog-in 180ms cubic-bezier(.2,.8,.2,1) both;
font-size: 14px;
}
.dialog h3 {
margin: 0; font-size: 16px; font-weight: 600;
letter-spacing: -0.01em; display: flex; align-items: center; gap: 10px;
color: var(--fg);
}
.dialog p.desc {
margin: 8px 0 0; color: var(--fg-muted); font-size: 13px;
line-height: 1.45;
}
.dialog .body { margin-top: 14px; }
.dialog .actions {
margin-top: 20px;
display: flex; justify-content: flex-end; gap: 8px;
}
.dialog button {
font: inherit; font-size: 13px; font-weight: 500;
padding: 8px 14px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg);
color: var(--fg); cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.dialog button:hover { background: var(--bg-muted); }
.dialog button:focus-visible {
outline: 2px solid var(--ring); outline-offset: 2px;
}
.dialog button.primary {
background: var(--accent); border-color: var(--accent); color: var(--accent-fg);
}
.dialog button.primary:hover {
background: color-mix(in srgb, var(--accent) 88%, black);
}
.dialog button.destructive {
background: var(--destructive); border-color: var(--destructive); color: var(--destructive-fg);
}
.dialog button.destructive:hover {
background: color-mix(in srgb, var(--destructive) 88%, black);
}
.dialog button:disabled { opacity: 0.5; cursor: not-allowed; }
.dialog .spinner {
display: inline-block;
width: 12px; height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: -2px;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.dialog textarea, .dialog input[type="text"] {
font: inherit; font-size: 13px;
width: 100%; padding: 8px 10px;
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
background: var(--bg); color: var(--fg);
resize: vertical;
}
.dialog textarea:focus, .dialog input[type="text"]:focus {
outline: 2px solid var(--ring); outline-offset: -1px; border-color: var(--ring);
}
.dialog textarea.invalid, .dialog input.invalid {
border-color: var(--destructive);
}
.dialog .helper {
font-size: 11px; color: var(--fg-muted);
margin-top: 4px; min-height: 16px;
}
.dialog .helper.error { color: var(--destructive); }
/* =========================================================== */
/* Icons (inline SVG, Lucide-style) */
/* =========================================================== */
.ico { width: 18px; height: 18px; stroke: currentColor; fill: none;
stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
flex-shrink: 0; }
.ico.muted { color: var(--fg-muted); }
.ico.destructive { color: var(--destructive); }
.ico.success { color: var(--success); }
/* =========================================================== */
/* Toast container (Variant C) */
/* =========================================================== */
.toast-container {
position: absolute; right: 16px; bottom: 16px;
display: flex; flex-direction: column-reverse; gap: 8px;
z-index: 11; width: 320px;
pointer-events: none;
}
.toast {
background: var(--fg);
color: var(--bg);
border-radius: var(--radius-sm);
padding: 10px 12px;
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column; gap: 6px;
pointer-events: auto;
animation: toast-in 220ms cubic-bezier(.2,.8,.2,1) both;
font-size: 13px;
}
.toast.error { background: var(--destructive); color: var(--destructive-fg); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.toast .title {
display: flex; align-items: center; gap: 8px;
font-weight: 600;
}
.toast .desc { font-size: 12px; opacity: 0.85; }
.toast .row {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
.toast .undo {
font: inherit; font-size: 11px; font-weight: 600;
padding: 4px 10px; border-radius: var(--radius-sm);
background: transparent; color: inherit;
border: 1px solid currentColor;
cursor: pointer;
text-transform: uppercase; letter-spacing: 0.06em;
}
.toast .undo:hover { background: rgba(255,255,255,0.12); }
/* =========================================================== */
/* Bulk-many list (Variant B-refined) */
/* =========================================================== */
.bulk-list {
margin-top: 14px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-subtle);
max-height: 200px; overflow-y: auto;
}
.bulk-item {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
display: flex; align-items: flex-start; gap: 8px;
font-size: 12px;
}
.bulk-item:last-child { border-bottom: none; }
.bulk-item .meta { flex: 1; min-width: 0; }
.bulk-item .meta .title {
font-weight: 500; color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bulk-item .meta .id {
font-family: var(--mono); font-size: 10px; color: var(--fg-subtle);
margin-top: 1px;
}
.bulk-item textarea {
font: inherit; font-size: 11px;
width: 100%; padding: 4px 6px;
border: 1px solid var(--border); border-radius: 4px;
background: var(--bg); color: var(--fg);
margin-top: 4px;
min-height: 22px; resize: vertical;
}
/* =========================================================== */
/* Decision legend */
/* =========================================================== */
.legend {
padding: 16px 32px 24px;
background: var(--bg);
border-top: 1px solid var(--border);
font-size: 12px; color: var(--fg-muted);
max-width: 1700px;
}
.legend h3 {
font-size: 11px; font-weight: 600; color: var(--fg);
letter-spacing: 0.08em; text-transform: uppercase;
margin: 0 0 8px;
}
.legend table { border-collapse: collapse; width: 100%; max-width: 1100px; }
.legend th, .legend td {
text-align: left; padding: 6px 10px;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.legend th { color: var(--fg); font-weight: 600; }
.legend td.yes { color: var(--success); }
.legend td.no { color: var(--fg-subtle); }
.legend td.partial { color: var(--accent); }
</style>
</head>
<body>
<header class="page">
<h1>Hermes Kanban — Native Dialog Prototypes</h1>
<p>
Four approaches to replacing <code>window.confirm()</code>,
<code>window.prompt()</code>, and <code>window.alert()</code> in
<code>plugins/kanban/dashboard/dist/index.js</code>. Click a page-level
trigger to fire the same flow in every variant simultaneously, or use the
per-variant buttons to fire flows unique to that variant.
</p>
<div class="controls">
<button data-trigger="move-done">Trigger: Mark Done (with summary)</button>
<button data-trigger="move-blocked">Trigger: Mark Blocked</button>
<button data-trigger="bulk-delete">Trigger: Bulk delete (3 tasks)</button>
<button data-trigger="archive-board">Trigger: Archive board</button>
<button data-trigger="remove-attachment">Trigger: Remove attachment</button>
<button data-trigger="error">Trigger: Error toast</button>
<button data-trigger="clear">Clear all stages</button>
</div>
</header>
<div class="board">
<!-- ============================================================ -->
<!-- VARIANT A — Conservative -->
<!-- ============================================================ -->
<section class="variant" data-variant="A">
<header>
<h2>Variant A</h2>
<div class="label">Conservative</div>
<p>Direct 1:1 mapping to the host's <code>ConfirmDialog</code>. Single-line input. Minimal chrome.</p>
</header>
<div class="stage" data-stage="A">
<div class="placeholder">Click a trigger to preview.</div>
</div>
<div class="note">
<strong>Trade-off:</strong> Safest to ship — zero new components, zero new
patterns. Fails the GPT-OSS review on three points: no multi-line
summary, validation triggers a SECOND dialog instead of inline, no bulk
affordance.
</div>
</section>
<!-- ============================================================ -->
<!-- VARIANT B — Strong-fit (Pro's pick) -->
<!-- ============================================================ -->
<section class="variant" data-variant="B">
<header>
<h2>Variant B</h2>
<div class="label">Strong-fit (Pro's pick)</div>
<p>Textarea + contextual SVG icon + inline validation + pluralized copy. What the design brief recommends.</p>
</header>
<div class="stage" data-stage="B">
<div class="placeholder">Click a trigger to preview.</div>
</div>
<div class="note">
<strong>Trade-off:</strong> Best baseline, but Pro suggested <em>either</em>
a disabled-button <em>or</em> an inline error — GPT-OSS caught that both
are needed (button disabled AND error visible) for screen-reader users.
Bottom-sheet on mobile is the right move but still has keyboard edge cases.
</div>
</section>
<!-- ============================================================ -->
<!-- VARIANT B-refined (Pro + GPT-OSS synthesis) -->
<!-- ============================================================ -->
<section class="variant" data-variant="B2">
<header>
<h2>Variant B-refined</h2>
<div class="label">Synthesis (recommended)</div>
<p>All of B's improvements + GPT-OSS fixes: auto-focus, dual-validation, cancellable-spinner, per-task summaries in bulk.</p>
</header>
<div class="stage" data-stage="B2">
<div class="placeholder">Click a trigger to preview.</div>
</div>
<div class="note">
<strong>Why this is the recommendation:</strong> Single contextual icon
(not four) keeps the title readable. Confirm button stays enabled until
textarea has content; the inline error appears on submit-attempted-empty
AND on blur if still empty. Cancel button stays clickable during the
PATCH (only the confirm shows the spinner) so users can abort slow
networks. Bulk-many shows an expandable list with per-task summary
fields.
</div>
</section>
<!-- ============================================================ -->
<!-- VARIANT C — Divergent (undo toast, non-destructive only) -->
<!-- ============================================================ -->
<section class="variant" data-variant="C">
<header>
<h2>Variant C</h2>
<div class="label">Divergent: undo toast</div>
<p>Skip the modal entirely for non-destructive moves. Optimistic UI + 5s undo in a bottom-right toast.</p>
</header>
<div class="stage" data-stage="C">
<div class="placeholder">Click a trigger to preview.</div>
<div class="toast-container" data-toasts></div>
</div>
<div class="note">
<strong>Trade-off:</strong> Radical speedup for routine moves, but breaks
the required-summary flow (you can't optimistically "complete" a task
that's missing required schema data). Best used as a
<em>complement</em> to B-refined — undo toast for the safe moves,
modal for <code>done</code>/<code>blocked</code>/<code>archive</code>.
</div>
</section>
</div>
<div class="legend">
<h3>Decision matrix — recommended pick: B-refined</h3>
<table>
<thead>
<tr><th>Capability</th><th>A</th><th>B</th><th>B-refined</th><th>C</th></tr>
</thead>
<tbody>
<tr><td>Centered modal (Radix)</td> <td class="yes"></td> <td class="yes"></td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Multi-line summary</td> <td class="no"></td> <td class="yes"></td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Single contextual icon</td> <td class="no"></td> <td class="partial">4</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Inline validation (no second dialog)</td><td class="no"></td> <td class="partial">~</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Disabled button + persistent error</td> <td class="no"></td> <td class="partial">~</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Cancellable spinner during PATCH</td> <td class="no"></td> <td class="no"></td> <td class="yes"></td> <td class="yes"></td></tr>
<tr><td>Bulk-many expandable list</td> <td class="no"></td> <td class="no"></td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Auto-focus textarea + mobile scroll</td> <td class="no"></td> <td class="partial">~</td> <td class="yes"></td> <td class="no"></td></tr>
<tr><td>Toast on success</td> <td class="no"></td> <td class="no"></td> <td class="no"></td> <td class="yes"></td></tr>
<tr><td>Toast on error</td> <td class="no"></td> <td class="no"></td> <td class="partial">opt.</td> <td class="yes"></td></tr>
<tr><td>Survives the required-summary flow</td> <td class="no"></td> <td class="yes"></td> <td class="yes"></td> <td class="no"></td></tr>
</tbody>
</table>
</div>
<script>
/* =========================================================== */
/* Shared icon library (inline SVG) */
/* =========================================================== */
const ICONS = {
check: '<svg class="ico success" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>',
archive: '<svg class="ico muted" viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><path d="M10 13h4"/></svg>',
pause: '<svg class="ico muted" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M10 9v6M14 9v6"/></svg>',
trash: '<svg class="ico destructive" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>',
paperclip:'<svg class="ico muted" viewBox="0 0 24 24"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>',
info: '<svg class="ico muted" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>',
x: '<svg class="ico" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>',
undo: '<svg class="ico" viewBox="0 0 24 24"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>',
};
/* =========================================================== */
/* Dialog factories */
/* Each factory: (stage, opts) => void; clears stage, mounts */
/* backdrop + dialog. */
/* =========================================================== */
function clearStage(stage) {
// Remove everything except the toast container (for variant C)
const toastContainer = stage.querySelector('.toast-container');
stage.innerHTML = '';
if (toastContainer) stage.appendChild(toastContainer);
}
function mountBackdrop(stage, dialog) {
const backdrop = document.createElement('div');
backdrop.className = 'backdrop';
backdrop.appendChild(dialog);
stage.appendChild(backdrop);
return backdrop;
}
/* ----- Variant A: Conservative -------------------------------- */
function A_moveDone(stage) {
clearStage(stage);
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>Mark this task as done?</h3>
<p class="desc">The worker's claim is released and dependent children become ready.</p>
<div class="body">
<input type="text" placeholder="Completion summary" />
</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="primary">Mark Done</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage);
dlg.querySelector('[data-act=confirm]').onclick = () => {
// No validation — just shows another alert if empty (current bug)
const v = dlg.querySelector('input').value.trim();
if (!v) { window.alert('Completion summary is required before marking a task done.'); return; }
clearStage(stage);
};
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
}
/* ----- Variant B: Strong-fit ----------------------------------- */
function B_moveDone(stage) {
clearStage(stage);
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${ICONS.check} Mark this task as done?</h3>
<p class="desc">The worker's claim is released and dependent children become ready.</p>
<div class="body">
<textarea rows="3" placeholder="Completion summary — this is stored as the task result."></textarea>
<div class="helper" data-helper></div>
</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="primary">Mark Done</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
const ta = dlg.querySelector('textarea');
const helper = dlg.querySelector('[data-helper]');
const confirm = dlg.querySelector('[data-act=confirm]');
// Pro suggested either disabled OR error — let's show the error path
confirm.onclick = () => {
if (!ta.value.trim()) {
helper.classList.add('error');
helper.textContent = 'Completion summary is required before marking a task done.';
ta.classList.add('invalid');
ta.focus();
return;
}
// Simulate PATCH
confirm.disabled = true;
confirm.innerHTML = '<span class="spinner"></span>Marking done…';
setTimeout(() => clearStage(stage), 1200);
};
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage);
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
}
/* ----- Variant B-refined: synthesis ---------------------------- */
function B2_moveDone(stage, count) {
clearStage(stage);
const isBulk = count && count > 1;
const label = isBulk ? `${count} selected tasks` : 'this task';
const title = isBulk ? `Mark ${count} tasks as done?` : 'Mark this task as done?';
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${ICONS.check} ${title}</h3>
<p class="desc">The worker's claim is released and dependent children become ready.</p>
<div class="body">
<textarea rows="3" autofocus placeholder="Completion summary for ${label}. This is stored as the task result."></textarea>
<div class="helper" data-helper></div>
${isBulk ? bulkList() : ''}
</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="primary" disabled>Mark Done</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
const ta = dlg.querySelector('textarea');
const helper = dlg.querySelector('[data-helper]');
const confirm = dlg.querySelector('[data-act=confirm]');
const cancel = dlg.querySelector('[data-act=cancel]');
const REQUIRED = 'Completion summary is required before marking a task done.';
// Auto-focus + ensure visible on mobile keyboards (best-effort)
setTimeout(() => {
ta.focus();
ta.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 50);
ta.addEventListener('input', () => {
const valid = ta.value.trim().length > 0;
confirm.disabled = !valid;
if (valid) {
ta.classList.remove('invalid');
helper.classList.remove('error');
helper.textContent = '';
}
});
ta.addEventListener('blur', () => {
if (!ta.value.trim()) {
ta.classList.add('invalid');
helper.classList.add('error');
helper.textContent = REQUIRED;
}
});
confirm.onclick = () => {
if (!ta.value.trim()) {
ta.classList.add('invalid');
helper.classList.add('error');
helper.textContent = REQUIRED;
ta.focus();
return;
}
// Cancel stays enabled; only confirm gets the spinner (GPT-OSS fix)
confirm.disabled = true;
confirm.innerHTML = '<span class="spinner"></span>Marking done…';
setTimeout(() => clearStage(stage), 1200);
};
cancel.onclick = () => clearStage(stage);
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
// Cmd/Ctrl + Enter submits
ta.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !confirm.disabled) {
confirm.click();
}
});
}
/* Helper: bulk list markup for B-refined bulk-many dialogs */
function bulkList() {
const items = [
{ id: 't_8c41', title: 'Refactor kanban dispatcher tick interval' },
{ id: 't_8d12', title: 'Add batch delete endpoint smoke test' },
{ id: 't_8e09', title: 'Document goal_mode lifecycle in user guide' },
];
return `
<div class="bulk-list">
${items.map(t => `
<div class="bulk-item">
<div class="meta">
<div class="title">${t.title}</div>
<div class="id">${t.id}</div>
<textarea rows="1" placeholder="Completion summary for this task…"></textarea>
</div>
</div>
`).join('')}
</div>
`;
}
/* ----- Generic destructive confirm (used by A/B for blocked/archive/delete) -- */
function makeConfirm(stage, opts) {
const { icon, title, desc, confirmLabel, destructive, requireReason } = opts;
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${icon} ${title}</h3>
<p class="desc">${desc}</p>
${requireReason ? `
<div class="body">
<textarea rows="2" placeholder="${requireReason}"></textarea>
<div class="helper" data-helper></div>
</div>
` : ''}
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="${destructive ? 'destructive' : 'primary'}">${confirmLabel}</button>
</div>
`;
const backdrop = mountBackdrop(stage, dlg);
const confirm = dlg.querySelector('[data-act=confirm]');
if (requireReason) {
const ta = dlg.querySelector('textarea');
confirm.disabled = true;
ta.addEventListener('input', () => { confirm.disabled = ta.value.trim().length === 0; });
}
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage);
confirm.onclick = () => {
confirm.disabled = true;
confirm.innerHTML = '<span class="spinner"></span>Working…';
setTimeout(() => clearStage(stage), 800);
};
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage); };
}
const makeA_confirm = (s, o) => makeConfirm(s, o);
const makeB_confirm = (s, o) => makeConfirm(s, o);
const makeB2_confirm = (s, o) => makeConfirm(s, o);
/* ----- Variant C: undo toast ----------------------------------- */
function C_undoToast(stage, message, desc) {
const container = stage.querySelector('.toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div class="title">${ICONS.check} ${message}</div>
${desc ? `<div class="desc">${desc}</div>` : ''}
<div class="row">
<span style="opacity:0.6; font-size: 10px;">Auto-dismiss in 5s</span>
<button class="undo" data-act="undo">Undo</button>
</div>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 5200);
toast.querySelector('[data-act=undo]').onclick = () => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 200ms';
setTimeout(() => toast.remove(), 220);
};
}
function C_errorToast(stage, message) {
const container = stage.querySelector('.toast-container');
const toast = document.createElement('div');
toast.className = 'toast error';
toast.dataset.sticky = '1'; // visual marker; doesn't actually stop dismissal
toast.innerHTML = `
<div class="title">${ICONS.x} ${message}</div>
`;
container.appendChild(toast);
// Longer dismiss for screenshot stability
setTimeout(() => toast.remove(), 30000);
}
/* =========================================================== */
/* Dispatch table — fires the right handler per variant */
/* =========================================================== */
const HANDLERS = {
'move-done': () => {
A_moveDone(stage('A'));
B_moveDone(stage('B'));
B2_moveDone(stage('B2'), 1);
C_undoToast(stage('C'), 'Task marked as done', 'The worker claim has been released.');
},
'move-blocked': () => {
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.pause,
title: 'Mark this task as blocked?',
desc: "The worker's claim is released.",
confirmLabel: 'Mark Blocked',
destructive: false,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
makeB2_confirm(stage('B2'), opts('B2'));
C_undoToast(stage('C'), 'Task marked as blocked', 'Worker claim released. Worker will re-prompt on unblock.');
},
'bulk-delete': () => {
const isBulk = true;
const count = 3;
const titleAll = isBulk
? `Permanently delete ${count} selected tasks?`
: 'Permanently delete this task?';
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.trash,
title: titleAll,
desc: 'This cannot be undone.',
confirmLabel: isBulk ? `Delete ${count} tasks` : 'Delete',
destructive: true,
requireReason: variant === 'B2' ? 'Optional: why are these being deleted?' : null,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
// B2 gets the bulk list version
clearStage(stage('B2'));
const dlg = document.createElement('div');
dlg.className = 'dialog';
dlg.innerHTML = `
<h3>${ICONS.trash} Permanently delete 3 selected tasks?</h3>
<p class="desc">This cannot be undone.</p>
<div class="body">${bulkList()}</div>
<div class="actions">
<button data-act="cancel">Cancel</button>
<button data-act="confirm" class="destructive">Delete 3 tasks</button>
</div>
`;
const backdrop = mountBackdrop(stage('B2'), dlg);
dlg.querySelector('[data-act=cancel]').onclick = () => clearStage(stage('B2'));
dlg.querySelector('[data-act=confirm]').onclick = () => {
dlg.querySelector('[data-act=confirm]').disabled = true;
dlg.querySelector('[data-act=confirm]').innerHTML = '<span class="spinner"></span>Deleting…';
setTimeout(() => clearStage(stage('B2')), 1200);
};
backdrop.onclick = (e) => { if (e.target === backdrop) clearStage(stage('B2')); };
C_errorToast(stage('C'), 'Bulk delete is destructive — undo toast not appropriate.');
},
'archive-board': () => {
const msg = "Archive board 'atm10-server'? It will be moved to boards/_archived/ so you can recover it later. Tasks on this board will no longer appear anywhere in the UI.";
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.archive,
title: "Archive board 'atm10-server'?",
desc: msg,
confirmLabel: 'Archive',
destructive: true,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
makeB2_confirm(stage('B2'), opts('B2'));
C_errorToast(stage('C'), 'Archive is destructive — undo toast not appropriate.');
},
'remove-attachment': () => {
const opts = (variant) => ({
icon: variant === 'A' ? '' : ICONS.paperclip,
title: 'Remove this attachment?',
desc: 'The file will be unlinked from this task. Other references are unaffected.',
confirmLabel: 'Remove',
destructive: true,
});
makeA_confirm(stage('A'), opts('A'));
makeB_confirm(stage('B'), opts('B'));
makeB2_confirm(stage('B2'), opts('B2'));
C_undoToast(stage('C'), 'Attachment removed', 'design-spec-v3.pdf');
},
'error': () => {
// Variant C: error toast is the only place toasts really shine.
[stage('A'), stage('B'), stage('B2')].forEach(s => {
const banner = document.createElement('div');
banner.style.cssText = 'position:absolute; top:12px; left:12px; right:12px; padding:10px 12px; background: color-mix(in srgb, var(--destructive) 10%, var(--bg)); border: 1px solid var(--destructive); color: var(--destructive); border-radius: var(--radius-sm); font-size: 12px;';
banner.textContent = 'Move failed: HTTP 409 — task is in status "todo", only running/ready/blocked can be completed.';
s.appendChild(banner);
});
C_errorToast(stage('C'), 'Move failed: HTTP 409');
},
'clear': () => {
[stage('A'), stage('B'), stage('B2'), stage('C')].forEach(s => clearStage(s));
},
};
function stage(name) { return document.querySelector(`[data-stage="${name}"]`); }
/* =========================================================== */
/* Wire up page-level triggers */
/* =========================================================== */
document.querySelectorAll('header.page .controls button').forEach(btn => {
btn.addEventListener('click', () => {
const handler = HANDLERS[btn.dataset.trigger];
if (handler) handler();
});
});
</script>
</body>
</html>
+208
View File
@@ -0,0 +1,208 @@
# Multiplexing Gateway
One gateway process can serve every profile in the install. The mode is opt-in
(`gateway.multiplex_profiles`, default `false`), and everything it changes
reverts the moment the flag is off. This document is the design rationale
referenced from `agent/secret_scope.py` ("Workstream A"): what is isolated per
profile, the mechanism that isolates it, and what deliberately stays
process-global.
## Overview
Without multiplexing, one gateway process serves exactly one profile — its
`.env`, sessions, skills, and platform adapters — and multi-profile installs
run one process per profile. Multiplexing collapses that into a single
process: the default profile plus every served named profile get their own
adapters, secrets, sessions, and cron ticks, while sharing one event loop, one
HTTP listener, one process lock, and one status surface.
The design constraint that shapes everything below: **profile A's turns must
never observe profile B's state**. Secrets, homes, sessions, and adapter lanes
are isolated per profile; anything that cannot yet be isolated fails closed or
is documented as a known limitation at the end of this document.
## The mode flag
- Config: `gateway.multiplex_profiles: true` (also accepted at top level).
Parsed in `gateway/config.py` with precedence env > config > default.
- Env override: `GATEWAY_MULTIPLEX_PROFILES` accepts explicit truthy/falsy
tokens only; a blank or unrecognized value returns "no override" so an empty
deployment secret cannot shadow a config opt-in.
- At startup, `GatewayRunner.__init__` calls
`agent.secret_scope.set_multiplex_active(...)` once. `_MULTIPLEX_ACTIVE` is
a plain module global, not a contextvar: it describes the deployment mode,
not a per-task value. Its only job is to arm the fail-closed behavior in
`get_secret()`.
## Scope composition
Every inbound event composes the same two context-local scopes before any
profile-owned code runs:
```
platform event
profile_routes match ──► served-set check ──► SessionSource.profile stamped
│ (gateway/profile_routing.py)
_profile_runtime_scope(profile_home) (gateway/run.py)
├── set_hermes_home_override(home) config / state.db / skills /
│ memory / sessions resolve here
└── set_secret_scope(profile .env + secret sources)
│ provider keys, platform tokens
agent turn (worker thread via copy_context())
scope unwound in finally
```
`_profile_runtime_scope` wraps every seam where profile-owned code executes:
secondary adapter startup, connect and reconnect, the primary platform event
handler, inbound preprocessing, `/model` and session-info resolution,
background tasks, and the agent turn itself. Config reloads run under the
default profile's scope so global gateway settings (`#64674`) resolve
consistently.
Both scopes are `contextvars`, so they propagate into executor worker threads
via `copy_context()` and unwind deterministically — nothing is written to
`os.environ`, ever.
## Workstream A: context-local secret scope
`agent/secret_scope.py` exists because the obvious implementation — union all
profile `.env` files into `os.environ` — leaks profile A's keys into profile
B's turns and into every subprocess spawned with `env=dict(os.environ)`.
- `build_profile_secret_scope(home)` merges the profile's `.env` with its
configured secret sources, skipping globals.
- `set_secret_scope(mapping)` installs it for the current task.
- `get_secret(name)` resolves: global allowlist → active scope → fallback.
The fallback is the load-bearing part:
- multiplexing **off**: reads `os.environ`, so single-profile gateways and
every non-gateway caller behave exactly as before;
- multiplexing **on**, no scope installed: **raises `UnscopedSecretError`**
rather than silently reading the process environment. An un-migrated call
site fails loud at that exact line instead of leaking another profile's
value.
- A small allowlist (`HERMES_HOME`, `HERMES_PROFILE`, proxy settings,
`API_SERVER_*` listener settings — but deliberately not `API_SERVER_KEY`)
stays global because those describe the process, not a profile.
Because the per-turn `.env` reload is a no-op under multiplexing, rotated
credentials are picked up through the profile scope on the next turn — never
via `os.environ`. This holds at the loader boundary, not just the gateway's
reload helper: `hermes_cli.env_loader.load_hermes_dotenv` skips the
process-global load whenever multiplexing is active *and* a profile-home
override is installed (import-time and cron callers hit it mid-turn), while
still hydrating the profile's external secret sources into its private
snapshot (`#77562`). The unscoped startup load is unchanged.
The same scope-authoritative rule covers the other `os.environ` seams a
routed turn can reach: `${VAR}` / `${env:VAR}` references in a profile's
`config.yaml` resolve through `get_secret` when a scope is installed
(`#84079`), and `.env` writes made under a scope (`save_env_value`, e.g. a
`/pair` grant mirror) update the installed scope mapping instead of the
process environment (`#88441`).
## The HERMES_HOME override
`hermes_constants.py` holds a context-local override consulted by
`get_hermes_home()` before the `HERMES_HOME` env var. Everything that resolves
paths through it — config, `state.db`, skills, memory, SOUL, sessions, kanban,
goals, plugin discovery, MCP startup — follows the active profile
automatically. `get_process_hermes_home()` exists for the few machine-level
assets that must not follow the override. `hermes_home_key()` gives
per-home registries a stable scope key. A one-shot warning (`#18594`) fires if
profile-scoped code runs without the override where one is expected.
## Inbound routing
`gateway.profile_routes` maps `(platform, guild_id, chat_id, thread_id)` to a
profile; matching is conjunctive, most-specific-first, with parent-chain chat
matching for threads. Routing only runs when multiplexing is active, and a
matched route whose target is outside the served set is rejected (the event is
dropped, not misdelivered). Full schema and matching rules:
`docs/profile-routing.md`.
## Serving selected profiles
`profiles_to_serve(multiplex, profile_allowlist)` in `hermes_cli/profiles.py`
is the single chokepoint for which profiles a multiplexer serves: default plus
every valid profile directory, optionally filtered by allowlist. A malformed
allowlist fails safe to default-only. The served set gates adapter startup,
cron ticking (`#69377`), `/p/<profile>/` HTTP admission, route eligibility,
and the runtime status surface. An excluded profile stays installed and can
still run its own standalone gateway.
## Per-profile persistence
`SessionStore` binds no database handle at construction (`#88532`). Session
DB handles are resolved at call time through the active HERMES_HOME override —
one cached handle per resolved `profiles/<name>/state.db` — so sessions land
in the owning profile's store even when the store object itself is shared.
Pairing stores are constructed per served profile.
## Per-bot session lanes
Session keys are namespaced by profile (`agent:main` for default,
`agent:<name>` for named profiles). Adapters carry `_owner_profile`
(installed at adapter configuration time, before any inbound event) because
adapter ingress runs before `SessionSource.profile` is stamped;
`_session_key_profile` resolves source stamp → owner profile → store
resolver. Text/media batching, active-session tracking, and the busy-session
guard are all keyed per lane, so two bots sharing a chat do not share a
session lane.
## Control plane
Desktop plugins reach the gateway only through the ws JSON-RPC door, so
profile enumeration and configuration live in
`tui_gateway/methods_profiles.py`: `profiles.list`, `profiles.create`,
`profiles.describe`, `profiles.configure`, `profiles.set_asset`,
`profiles.get_asset`. Reads and writes run under the target profile's
HERMES_HOME override. Asset writes are atomic, type- and size-capped.
## Failure modes
- Fatal at startup: multiplex config errors and a secondary profile enabling a
port-binding platform (`MultiplexConfigError`,
`SecondaryPortBindingConfigError`) — one shared HTTP listener is owned by
the default profile.
- Skipped, not fatal: a single misconfigured secondary adapter is skipped with
a warning rather than taking down the multiplexer.
- Fail-closed: unscoped `get_secret()` under multiplexing raises; a routed
event targeting an unserved profile is dropped; an unscoped `/p/` request
enters the default profile's scope (`#61276`) rather than an undefined one.
- Fallback: an external `cron.provider` does not support multiplexing and
falls back to the built-in ticker with a warning.
## Known limitations
Process-global state that is not yet profile-scoped:
| Surface | State at time of writing |
| --- | --- |
| MCP discovery and tool registration | Process-global; the first profile to build an agent wins the discovery slot. Full per-profile MCP registries are tracked in `#67605`. |
| Terminal / sandbox env (`TERMINAL_*`) | Global by allowlist; tools read it from the process environment. |
| Built-in tool registry | Built-ins are process-global; plugin-registered tools are overlaid per profile via `hermes_home_key()`. |
| Provider/capability registries | Same hybrid overlay pattern (browser, image-gen, TTS, transcription, video-gen, web-search, secret sources). |
| HTTP listener, relay ingress, process lock | One per process, owned by the default/active profile. Per-profile `runtime_status.json` is still written. |
## Non-goals
Multiplexing isolates *profiles*; it does not authenticate or authorize *end
users*. A profile is a configuration, not a person: the gateway trusts its
transport and its routing table to decide which profile an event belongs to.
Request-level identity and per-user authorization above the profile layer are
out of scope for this document.
## Related
- `docs/profile-routing.md` — inbound routing schema and matching rules.
- `website/docs/user-guide/multi-profile-gateways.md` — user-facing guide,
including the standalone one-gateway-per-profile alternative.
- `agent/secret_scope.py`, `hermes_constants.py`, `gateway/profile_routing.py`,
`gateway/run.py` (`_profile_runtime_scope`), `hermes_cli/profiles.py`
(`profiles_to_serve`), `gateway/session.py`, `tui_gateway/methods_profiles.py`.
+146
View File
@@ -0,0 +1,146 @@
# Profile Builder — Dashboard-Native, Full-Featured Profile Creation
Status: design proposal (not yet implemented)
Author: drafted for Teknium
Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`)
## Why this, not the CLI wizard
PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal.
The decision is to **not** build the profile-creation experience in the CLI.
The dashboard already owns mature, separate pages for every element a profile
needs, and a profile is just a HERMES_HOME directory — so the dashboard is the
right home for a full-featured builder, and it can reuse everything that
already exists.
A profile = a full `~/.hermes/profiles/<name>/` directory with its own:
- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills
- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs)
- `.env` — secrets
- `SOUL.md` / `USER.md` — identity
So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model
change needed. The gap is purely UX: creation today is a thin modal
(name + clone + model + description), and you can only compose skills/MCPs
*after* the profile exists, by visiting other pages and remembering to scope
them.
## What already exists (reuse, don't rebuild)
| Element | Existing page | Existing API | Profile-scopable? |
|---|---|---|---|
| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) |
| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint |
| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override |
| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write |
| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 |
## Two architectural seams found while grounding this design
These are load-bearing — they change the implementation, not just the polish.
### Seam #1 — hub-skill install cannot use the HERMES_HOME override
`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module
import time**. The context-local `set_hermes_home_override()` swap (which makes
`_write_profile_model` and the MCP write land in the target profile) does NOT
retroactively rebind that already-imported module global. So a data-layer wrap
of hub install would write into the dashboard's *own* active profile, not the
new one.
The correct mechanism is the existing subprocess path: `_spawn_hermes_action`
runs `python -m hermes_cli.main <subcommand>`, and `_apply_profile_override()`
re-reads `sys.argv` at import in the fresh child. Prepend `-p <profile>`:
```python
_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install")
```
A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound
from the start, so `SKILLS_DIR` resolves to `<profile>/skills/`. Correct by
construction.
### Seam #2 — hub installs are async, so create cannot be fully atomic
Built-in/optional skill enabling and MCP writes are **synchronous config ops**
and can be part of the create call. Hub installs are long-running git fetches
spawned detached (`_spawn_hermes_action` returns a PID immediately). So the
create flow is:
1. `create_profile()` — make the dir (synchronous)
2. write model (synchronous, HERMES_HOME override)
3. write selected MCP servers (synchronous, HERMES_HOME override)
4. seed/enable selected built-in + optional skills (synchronous)
5. spawn `hermes -p <profile> skills install <id>` per hub skill (async, returns PIDs)
Steps 14 commit before the response; step 5 returns a list of action PIDs the
UI polls (same pattern as today's SkillsPage hub install). The builder's
"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the
final screen shows live install progress for the hub skills.
## Proposed backend change (small, follows existing patterns)
Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
```python
class ProfileCreate(BaseModel):
name: str
clone_from: Optional[str] = None
# Backward compatibility for older dashboard/desktop clients.
clone_from_default: bool = False
clone_all: bool = False
no_skills: bool = False
description: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
# NEW — all optional, all best-effort post-create (profile already exists)
mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override
builtin_skills: List[str] = [] # synchronous enable/seed
hub_skills: List[str] = [] # async spawn, returns PIDs
```
The endpoint already does best-effort post-create steps (`seed_profile_skills`,
`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill
spawn) in the same style — a failure in any of them must not 500 the create,
since the profile dir already exists and the user can fix it from the relevant
page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for
the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`).
## Proposed frontend — dedicated builder page `/profiles/new`
A full page (not the cramped modal), stepped, each step reusing the existing
page's component + API, targeted at the new profile:
```
① Identity Name + Description (+ optional clone-from existing profile)
② Model Provider + model picker (reuse ModelsPage picker)
③ Skills Tabs: Built-in · Optional · Hub-search
multi-select; "Start from default bundle" preset button
④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form)
⑤ Review Blueprint preview → Create
→ progress screen for async hub installs
```
Nothing writes to disk until ⑤.
## Open product decisions (need Teknium)
1. **Skills seeding default.** Fresh profiles auto-seed the default bundle
today. In the builder, should the skill step **replace** the bundle (pick
exactly what you want; offer a "start from default bundle" preset) or
**augment** it? Recommendation: replace + preset button.
2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow:
SOUL editing, multi-agent fleets later) vs a bigger create modal on
ProfilesPage. Recommendation: dedicated page — matches "full-featured / way
more options."
## Verification plan (when built)
- Backend E2E with isolated HERMES_HOME: POST a full create body
(name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new
profile dir has the model in config.yaml, both MCP servers in config.yaml,
the builtin skills enabled, and a spawned PID for the hub skill. Negative:
a bad MCP entry must not 500 the create.
- `cd web && npm run build` (no JS test suite in web/).
- Targeted: `pytest tests/<web_server profile tests> -k profile_create`.
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
# Multi-gateway deployment
Hermes supports multiple gateway processes running concurrently — one per profile
(default, writer, admin, coder, researcher). Each gateway opens its own connection
to platform APIs and delivers messages for its profile's subscribers.
Task subscriptions also cover review feedback. A `changes_requested` review
event is delivered as an actionable review-BLOCK notification. Subscriptions
using `notify+wake` additionally wake the exact originating chat/thread/session
so the controller inspects the existing card and current run; `notify` remains
passive-only and `wake` remains wake-only. Review feedback never creates,
unblocks, requeues, or otherwise mutates a task.
## Single-dispatcher posture
Only one gateway owns the kanban dispatcher. The owning gateway keeps
`kanban.dispatch_in_gateway: true` (the default); every other gateway sets it
to `false`.
**Why this matters:** dispatching is single-owner so multiple gateways do not
race to spawn the same work. Notification delivery is profile-owned instead:
each gateway polls only subscriptions for profiles whose platform adapters it
hosts. The atomic event claim prevents duplicate delivery across watcher
processes.
## Configuration
On the dispatch-owning gateway (typically the `default` profile), no change is
needed. On every other profile gateway, add to `~/.hermes/config.yaml`:
```yaml
kanban:
dispatch_in_gateway: false
```
Or set the env var: `HERMES_KANBAN_DISPATCH_IN_GATEWAY=false`
## What each gateway does
| Gateway role | dispatch_in_gateway | Opens subscribed board DBs? | Dispatcher | Notifier |
|---|---|---|---|---|
| default (confirmed dispatch-lock owner) | true (default) | yes | yes | owned profiles + legacy unstamped subscriptions |
| writer, admin, coder, etc. | false | yes, when the profile has subscriptions | no | that gateway's owned profiles |
Non-dispatch gateways still deliver messages for their own platform adapters
(Telegram, Discord, etc.). They do not dispatch tasks, and they skip boards
that have no subscriptions owned by their profiles.
+401
View File
@@ -0,0 +1,401 @@
# Micro-compaction
**A way to amortize the cost of compression.**
Long conversations eventually outgrow the model's context window, and something
has to be thrown away or summarized. Hermes has always done this in one batch:
when the transcript crosses a threshold, the session stops, a large chunk of the
middle is summarized in a single call, and the conversation resumes. That works,
but the whole bill comes due at once — one visible pause, one big summarization
request, at whatever moment you happened to cross the line.
Micro-compaction pays the same bill in instalments. After each completed turn,
Hermes folds the single oldest un-absorbed exchange into a running summary. The
work is the same work; it just happens continuously, a piece at a time, instead
of all at once in the middle of your session.
It is not free and it is not a magic bullet, and it is **off by default**
`compression.micro_compact: true` turns it on. Each pass is a real call to the
compression model, and it runs at the end of a turn — your answer has already
streamed, but the turn does not close until the pass finishes. Each pass also
rewrites already-sent history, which breaks the provider prompt-cache prefix
every turn; read [Prompt caching](#prompt-caching--the-cost-you-are-opting-into)
before enabling it, because for some setups that cost exceeds the benefit.
What the feature gives you is a **tuning option**: you choose how the
compression cost is distributed, and which model pays it. See
[Choosing a compression model](#choosing-a-compression-model), because that
choice matters more than anything else here.
**The tradeoff is that knowledge gets a little earlier than you may be used to.**
Because compaction is always running, older parts of the conversation become
summaries sooner than they would under batch compaction — which leaves
everything verbatim until the window actually fills. Detail from earlier in the
session turns second-hand faster. You trade some of that fidelity for never
eating one long stall, and for a context window that stays consistently smaller
rather than sawtoothing up to the threshold and back.
---
## What it does
After every turn that finishes normally, `finalize_turn` asks the context
compressor to absorb **one** exchange:
1. Find the oldest exchange that hasn't been summarized yet.
2. Send just that exchange, plus the current running summary, to the auxiliary
summarization model.
3. Replace those messages in the transcript with a single summary marker
carrying the updated running summary.
One exchange per turn. The per-turn cost stays bounded no matter how long the
conversation gets.
An **exchange** is one full agent turn: an assistant message together with its
tool results and any follow-up assistant iterations, up to the next user
message. In tool-heavy work that's where the bulk of the tokens live — a
file read or a command's output dwarfs the surrounding prose — which is why
absorbing one exchange at a time is worth doing at all. Taking the whole turn
(rather than a single assistant+tools group) also keeps the transcript's role
alternation strictly valid: the summary marker is an assistant-role message,
and a full turn is always bounded by user messages on both sides.
## Your messages are never compacted
An exchange deliberately starts at the *assistant* message. Micro-compaction
walks straight past user messages to get there, so **what you typed is never
summarized** — your prompts stay verbatim for the entire session, no matter how
long it runs or how many times compaction fires.
This is the most useful property of the whole design, and it's worth being
explicit about why. What the assistant produces is largely an account of what it
did: it read this file, it ran that command, it got this result. That kind of
narration survives summarising with very little loss — "it did it this way" is
about as informative compressed as it was in full. Your instructions are a
different kind of thing. They're the intent everything else is derived from, and
they cannot be reconstructed from the work that followed. Paraphrasing "use the
existing retry helper, don't add a new one" into a summary is exactly how an
agent ends up confidently doing the thing you told it not to, six turns later.
So the asymmetry is on purpose: compact the derived material, keep the source of
truth. The cost is a floor on how small the middle can get, since user turns
accumulate and are never absorbed. In practice that floor is low — a prompt is
normally a tiny fraction of what a single tool result costs — but it is a real
floor. If you routinely paste 1020K-token prompts, that weight stays in context
by design.
## What it never touches
Two more regions are protected and stay verbatim:
- **The head** — the system prompt and the opening messages, so the session's
founding instructions are never paraphrased.
- **The tail** — a token-budgeted window of the most recent messages, so
everything that's immediately relevant is still there in full.
Micro-compaction only ever works in the middle, between those two.
## How it works
### The cursor
The compressor keeps a cursor: the index of the first message not yet absorbed.
Each successful pass advances it past the exchange it just summarized.
If that in-memory cursor is missing or out of range — a fresh process, a resumed
session — it's recovered by scanning the transcript for the last summary marker
and resuming just after it. The transcript itself is the source of truth, so
resuming a session doesn't re-summarize work already done.
### The rolling summary
Rather than keeping a pile of per-exchange summaries, there is exactly one
running summary that each new exchange is merged into. The summarizer is asked
to fold in the new material's decisions, requirements, file paths and open
questions, drop details that are no longer relevant, and preserve the existing
structure. It's also explicitly instructed to replace any credentials it
encounters with `[REDACTED]`.
Because that summary is cumulative, only the newest marker is kept in the
transcript. Earlier markers are strictly redundant — the current summary already
contains everything they held — so they're dropped as they're superseded. This
matters more than it sounds: leaving them in place stacks near-duplicate copies
of the same text, each with its own heading and end-marker scaffolding, and the
transcript grows on every turn instead of shrinking.
### Defrag
Merge into a summary often enough and it gets baggy — repetitive, and larger
than the material justifies. When the running summary crosses a token threshold
(2000 by default), the next pass **defrags**: one auxiliary call re-summarizes
the running summary *itself* into a fresh compact version, and the summary
marker in the transcript is rewritten in place.
Defrag never touches the transcript's structure — no messages are absorbed or
spliced, the cursor doesn't move, and user turns are untouched. It processes
only the accumulated summary text, never conversation messages, so the
"your messages are never compacted" guarantee holds through it.
### Staying in step with the session database
The in-memory splice alone isn't enough. Hermes's normal session flush is
append-only, so the original rows would stay marked active and a resume would
load *both* the summary and the messages it replaced — putting the session
straight over the context limit.
So each pass also calls `archive_and_compact`, which atomically soft-archives the
active rows and inserts the compacted set. The messages are then stamped as
already-persisted so the append-only flush that follows skips them. If that
database step fails, it's logged and the session continues; the resume would
double-load until the next batch compression cleans up.
### When the summarizer fails
A summarization call can fail — the auxiliary model is unreachable, out of quota,
or the exchange itself is somehow unsummarizable. The transcript is left
untouched and the failure is counted.
If the *same* exchange fails three times in a row, the cursor is advanced past it
anyway. Without that, one bad exchange would be retried on every single turn
forever. Those skipped messages stay in the transcript and get picked up by the
next batch compaction.
## Interaction with batch compaction
Micro-compaction doesn't replace batch compaction — it defers it. Threshold-based
compaction is still there and still fires if the window fills anyway, and its
summary markers are the same format, so the two interoperate. In practice
micro-compaction keeps the transcript far enough below the threshold that the
batch path fires much less often.
## Configuration
Micro-compaction is **off by default**. Turn it on explicitly:
```yaml
compression:
micro_compact: true # default: false
micro_compact_every_n_turns: 1 # cadence — how often a pass runs
micro_compact_defrag_threshold_tokens: 2000
```
With `micro_compact` unset or `false` Hermes behaves exactly as it always has:
batch-only compaction. Everything else about compression is unchanged.
`micro_compact_every_n_turns` is the knob that matters most after the on/off
switch, because it sets how often you pay the cache break described below. At
`1` a pass runs after every completed turn: the most aggressive reclaim, and
one broken prefix per turn. At `5` you get a fifth of the breaks and a fifth of
the reclaim rate, which is the right direction if your sessions are long-lived
and your provider's cache discount is deep. Values below `1` are clamped to `1`
rather than silently disabling the feature. The counter advances per turn, not
per committed pass, so a turn with nothing to absorb still moves the cadence
along and cannot wedge it.
`micro_compact_defrag_threshold_tokens` is when the rolling summary gets
re-summarized instead of growing forever — see [Defrag](#defrag).
It ships opt-in rather than on because of the prompt-cache cost described in
the next section — that cost is real, it is not universally worth paying, and
it should be a decision you make rather than one you inherit.
## Prompt caching — the cost you are opting into
Read this before enabling the feature. It is the strongest argument against it.
A long-lived conversation reuses a cached prompt prefix every turn, and cached
input tokens are billed at a fraction of uncached ones. That discount survives
only as long as the prefix does not change. **A micro-compaction pass rewrites
already-sent history**, which invalidates the prefix from the rewrite point
onward — so with micro-compaction on, you break the cache *every turn* instead
of once per batch compaction.
This is the same cost the proactive prune deliberately avoids. That path gates
itself behind `compression.proactive_prune_min_reclaim_tokens` (4096 by
default) precisely so its rewrites stay, in the words of the config comment,
"one big episodic break instead of a tiny break every tool iteration."
Micro-compaction has no equivalent *reclaim-size* gate — a pass commits
whatever the one absorbed exchange happened to save, large or small. What it
has instead is a *frequency* dial, `micro_compact_every_n_turns`. Raising it
makes the breaks rarer and more episodic, which is the same end the prune's
gate serves by a different route, though it gets there by absorbing less rather
than by waiting for a bigger win. If you want the prune's exact semantics here,
a reclaim threshold on micro-compaction is the obvious follow-up and does not
exist yet.
So the honest framing is a trade of one cost for another, not a saving:
| | Batch-only (default) | Micro-compaction on |
|---|---|---|
| Compression stalls | One long stall at the threshold | Spread across turns |
| Context occupancy | Sawtooths up to the threshold | Stays low and flat |
| Cache prefix | Intact between compactions | Broken every turn |
Which side wins depends on numbers specific to you: how much your provider
discounts cached input, how large your prefix is, how long your sessions run,
and how much a mid-session stall actually costs you. On a provider with a deep
cache discount and a big prefix, the per-turn invalidation can plausibly cost
more than the stall it removes. Measure your own sessions — see
[Measuring it](#measuring-it) — rather than assuming.
## Choosing a compression model
Micro-compaction uses the `auxiliary.compression` model:
```yaml
auxiliary:
compression:
provider: openai-api
model: <your choice>
base_url: <endpoint>
```
This is the single most important knob, and there is no universally right
answer — it depends on your hardware and what you are willing to trade.
Each pass sends the running summary plus one exchange, so the prompt is small
(a few thousand tokens) but the call happens **every turn**, at the end of the
turn. Two properties matter:
- **Latency dominates.** Because a pass runs per turn, its wall-clock cost is
felt repeatedly. A model that takes 30 seconds turns every turn into a turn
plus 30 seconds.
- **Reasoning models are a poor fit.** Merging one exchange into a summary is
mechanical work. A thinking model will spend reasoning tokens on it and be
substantially slower than a plain instruct model of similar size, for no
benefit to the output.
Some measured points, on one particular setup — treat them as illustrations of
the shape, not as recommendations:
| model | observed |
|---|---|
| 7B 4-bit instruct, local (MLX, Apple Silicon) | ~31s per pass; box also serving other work |
| large MoE reasoning model, remote GPU | noticeably slower still — thinking tokens on a summarisation task |
The pattern is that a small, fast, non-reasoning instruct model is usually the
right shape, and that a bigger or "smarter" model is often worse here rather
than better. Where that lands for you depends on what you have to run it on.
If passes feel too slow, your options in rough order of effect are: pick a
faster or smaller compression model; give it a less contended host; or turn
micro-compaction off and go back to batch compaction.
## Measuring it
Micro-compaction is not primarily a token-saving or time-saving optimisation,
and judging it on tokens saved will undersell it. The two things it actually
buys you are:
1. **The long pause is amortized.** The same summarization work happens, but as
small increments after turns instead of one stall in the middle of a session.
2. **Your context lasts longer.** Because the middle is continuously reclaimed,
occupancy stays low instead of sawtoothing up to the threshold. A session
runs much further — often indefinitely — before it needs a hard compaction
at all.
So the number that matters is **occupancy**: how full the window is being kept,
as a percentage of the compaction threshold. A session that holds steady around
40% has headroom to keep going; one climbing through 90% is about to stall. The
second number is **how many batch compactions actually fired** — ideally none.
A session can save nothing on paper and still be a clear win on both counts.
Every pass emits one content-free JSON line, in the same style as the batch
compaction telemetry:
```
micro compaction telemetry: {"event":"micro_compaction","outcome":"absorbed",
"tokens_before":12739,"tokens_after":12060,"tokens_delta":-679,
"occupancy_pct":38.4,"threshold_tokens":34816,"context_limit":40960,
"exchange_tokens":868,"rolling_summary_tokens":31,"passes_total":1,
"tokens_saved_total":679,"duration_ms":14,...}
```
`occupancy_pct` is `tokens_after` as a share of the compaction threshold -- the
headroom figure. It is null when the model's window has not been resolved yet:
the telemetry reads only the cached value, because resolving it can issue a
synchronous `/models` probe and telemetry must never be what blocks a turn.
`tokens_delta` is negative when the pass shrank the transcript.
`tokens_saved_total` and `passes_total` accumulate across the session, so a whole
run can be summarised from its last line. No transcript content appears in the
payload — only counts.
To turn a log into an answer:
```
python scripts/micro_compaction_report.py [--per-session] [LOGFILE ...]
```
Defaults to `$HERMES_HOME/logs/agent.log`. It reports passes, outcome mix, net
tokens saved, mean absorbed-exchange size and pass durations.
### What it looks like when it is working
One real session — a 3.5 hour whole-project code review, ~75K tokens of
transcript, 400K window, compaction threshold at 320K:
| pass | messages | tokens | delta | occupancy | duration |
|---|---|---|---|---|---|
| 1 | 40 -> 39 | 27,479 -> 27,778 | +299 | 8.7% | 2.2s |
| 2 | 61 -> 59 | 48,676 -> 48,128 | -548 | 15.0% | 4.5s |
| 3 | 70 -> 67 | 58,309 -> 55,915 | -2,394 | 17.5% | 9.1s |
| 4 | 84 -> 80 | 75,251 -> 69,818 | -5,433 | 21.8% | 36.2s |
| 5 | 84 -> 80 | 74,659 -> 70,264 | -4,395 | 22.0% | 31.2s |
Three things to read off it.
**Occupancy flattened.** It climbed to about 22% and stopped. The last two
passes are identical (84 -> 80 messages); between them the conversation added
4,841 tokens and micro-compaction reclaimed 4,395. That is equilibrium: the
window holds steady instead of marching toward the threshold.
**No batch compaction fired.** Across the whole session the long pause never
happened.
**Reclamation only ramps after the tail budget.** The first passes recovered
almost nothing, because below the tail budget (here 64,000 tokens, 16% of the
window) nearly the whole transcript is protected tail and there is very little
that may be touched. Early sessions legitimately show no passes at all.
And the cost, stated plainly: passes ran 2 to 37 seconds, median around 31, on
a small local model that was also serving other work. Roughly two minutes of
summarisation spread across three and a half hours. Against one batch
compaction of a 75K-token middle that is still the better trade, but a
37-second increment is not a rounding error. See
[Choosing a compression model](#choosing-a-compression-model).
### Reading the numbers honestly
**The first pass in a session usually costs tokens rather than saving them.**
Inserting the summary marker carries a fixed ~400 tokens of scaffolding — the
compaction preamble, the historical heading, the end marker — and on pass one
that is paid against a single absorbed exchange. A first pass showing
`tokens_delta: +330` is not a malfunction.
From the second pass on, the marker is *replaced* rather than added, so the
scaffolding is already paid for and each absorbed exchange is close to pure
saving. The break-even is normally the second or third pass. This is why the
per-session view matters more than any single line: judge the feature on a
session's trajectory, not on one turn.
The plainer human-readable lines are still there too:
```
Micro-compaction: 37 -> 36 messages
Micro-compaction defrag: rolling summary re-summarized (1843 chars)
Micro-compaction: skipping exchange at cursor 12 after 3 consecutive failures
```
Message counts move by small amounts — that's expected. The token count is where
the effect shows: absorbing one tool-heavy exchange can drop hundreds of tokens
while changing the message count by one or two.
## Failure behaviour
Micro-compaction is best-effort throughout. The call in `finalize_turn` is wrapped
so that any exception is logged and swallowed — a failure returns the conversation
unchanged and the turn completes normally. It can degrade, but it shouldn't be
able to break a session.
+261
View File
@@ -0,0 +1,261 @@
# Hermes Middleware
Hermes middleware is the behavior-changing companion to observer hooks.
Observer hooks report what happened. Middleware can change what happens by
rewriting a request before execution or by wrapping the execution callback
itself.
This contract is intentionally backend-neutral. A plugin can use it for local
policy, request shaping, tracing, adaptive routing, cache control, sandbox
selection, or handoff to runtimes such as NeMo Relay without changing Hermes'
planner, model provider adapters, tool registry, memory, or CLI UX.
With middleware enabled, plugins can:
- Rewrite LLM provider request kwargs before Hermes calls the provider.
- Rewrite tool arguments before guardrails, approval checks, hooks, and tool
execution see them.
- Wrap the actual LLM execution callback while preserving Hermes retry,
streaming, interrupt, and hook behavior.
- Wrap the actual tool execution callback while preserving Hermes guardrails,
approval, post-tool hooks, and tool-result transformation.
## Contract
Plugins register middleware from `register(ctx)`:
```python
def register(ctx):
ctx.register_middleware("llm_request", on_llm_request)
ctx.register_middleware("llm_execution", on_llm_execution)
ctx.register_middleware("tool_request", on_tool_request)
ctx.register_middleware("tool_execution", on_tool_execution)
```
Every middleware callback receives:
- `telemetry_schema_version`: currently `hermes.observer.v1`
- `middleware_schema_version`: currently `hermes.middleware.v1`
- Runtime context such as `session_id`, `task_id`, `turn_id`,
`api_request_id`, `provider`, `model`, `api_mode`, `tool_name`, and
`tool_call_id` when applicable.
Supported middleware kinds:
| Kind | Payload | Return shape | Purpose |
| --- | --- | --- | --- |
| `llm_request` | `request`, `original_request` | `{"request": {...}}` | Replace effective provider kwargs before provider execution. |
| `tool_request` | `tool_name`, `args`, `original_args` | `{"args": {...}}` | Replace effective tool args before hooks, guardrails, approvals, and execution. |
| `llm_execution` | `request`, `original_request`, `next_call` | Any provider response | Wrap or replace the actual provider call. |
| `tool_execution` | `tool_name`, `args`, `original_args`, `next_call` | Any tool result | Wrap or replace the actual tool call. |
Request middleware can return optional trace fields:
```python
return {
"request": updated_request,
"source": "my-plugin",
"reason": "selected fallback model",
}
```
Hermes stores those trace entries in later observer hook payloads as
`middleware_trace`.
Execution middleware receives a `next_call` callback. Call it to continue the
chain:
```python
def on_tool_execution(**kwargs):
result = kwargs["next_call"](kwargs["args"])
return result
```
If multiple plugins register the same execution middleware kind, Hermes runs
them as a nested chain in registration order. Middleware failures are fail-open:
Hermes logs a warning and continues with the next middleware or the base
runtime path.
## Execution Order
### LLM Calls
For each provider request, Hermes applies middleware in this order:
1. Build provider kwargs from the current conversation.
2. Apply `llm_request` middleware.
3. Emit `pre_api_request` observer hooks with the effective request.
4. Run provider execution through `llm_execution` middleware.
5. Emit `post_api_request` or `api_request_error` observer hooks.
Request middleware sees the full provider kwargs, including `messages` or
Responses API `input`, model settings, tool definitions, stream options, and
provider-specific options. Execution middleware receives the same effective
request plus `next_call`.
### Tool Calls
For each tool call, Hermes applies middleware in this order:
1. Parse and coerce model-provided tool arguments.
2. Apply `tool_request` middleware.
3. Run the normal Hermes pre-execution path against the effective arguments:
tool availability checks, observer block directives, guardrails, and
approval checks.
4. Run tool execution through `tool_execution` middleware.
5. Emit `post_tool_call` observer hooks.
6. Apply `transform_tool_result` hooks before the result is appended back into
conversation context.
Tool request middleware runs before approval checks. Use it carefully: a
rewritten path, command, or URL is the value downstream policy will evaluate.
## Enablement
Middleware only runs for enabled plugins. For a bundled plugin:
```bash
hermes plugins enable <plugin-name>
```
For isolated local testing, use one `HERMES_HOME` for plugin enablement and the
agent run:
```bash
export HERMES_HOME=/tmp/hermes-middleware-test
mkdir -p "$HERMES_HOME"
hermes plugins enable <plugin-name>
hermes chat --query 'Reply exactly ok'
```
For source checkouts, prefer the source command so the runtime sees plugins and
middleware from the working tree:
```bash
uv sync
uv run hermes plugins enable <plugin-name>
uv run hermes chat --query 'Reply exactly ok'
```
## Generic Plugin Examples
The examples below are intentionally small. They show the middleware contract
shape without depending on NeMo Relay.
### LLM Request Middleware
This plugin tags provider requests and records a middleware trace entry:
```python
def register(ctx):
ctx.register_middleware("llm_request", tag_llm_request)
def tag_llm_request(**kwargs):
request = dict(kwargs["request"])
extra_body = dict(request.get("extra_body") or {})
extra_body.setdefault("metadata", {})["hermes_middleware_demo"] = True
request["extra_body"] = extra_body
return {
"request": request,
"source": "middleware-demo",
"reason": "tagged provider request",
}
```
The effective request is passed to `pre_api_request`, provider execution, and
`post_api_request`.
### Tool Request Middleware
This plugin constrains `terminal` calls to a known working directory:
```python
def register(ctx):
ctx.register_middleware("tool_request", normalize_terminal_workdir)
def normalize_terminal_workdir(**kwargs):
if kwargs.get("tool_name") != "terminal":
return None
args = dict(kwargs["args"])
args.setdefault("workdir", "/tmp/hermes-middleware-demo")
return {
"args": args,
"source": "middleware-demo",
"reason": "defaulted terminal workdir",
}
```
Because this runs before hooks and approvals, downstream telemetry and policy
observe the rewritten `workdir`.
### LLM Execution Middleware
This plugin wraps the provider call and preserves the raw provider response:
```python
import time
def register(ctx):
ctx.register_middleware("llm_execution", time_llm_execution)
def time_llm_execution(**kwargs):
started = time.monotonic()
response = kwargs["next_call"](kwargs["request"])
elapsed_ms = int((time.monotonic() - started) * 1000)
print(f"llm_execution elapsed_ms={elapsed_ms}")
return response
```
Return the same response shape Hermes expects from the provider adapter. Do not
wrap the response in a plugin-specific envelope unless the rest of the runtime
expects that envelope.
### Tool Execution Middleware
This plugin wraps tool execution while preserving the tool result:
```python
def register(ctx):
ctx.register_middleware("tool_execution", annotate_tool_execution)
def annotate_tool_execution(**kwargs):
result = kwargs["next_call"](kwargs["args"])
# Metrics, logging, or external routing can happen here.
return result
```
Execution middleware may call `next_call(modified_args)` to pass a changed
payload to later middleware and the base tool dispatcher.
Plugin-specific examples should live with the plugin that owns the behavior.
NeMo Relay execution middleware is installed through an explicitly selected
Relay `plugins.toml`; see
[Relay shared metrics](../observability/relay-shared-metrics.md).
## Safety Notes
- Middleware should be deterministic for the same input unless it is explicitly
routing to a dynamic external system.
- Request middleware should return complete replacement payloads, not partial
patches.
- Execution middleware should call `next_call(...)` exactly once unless it is
intentionally short-circuiting execution.
- If execution middleware raises before calling `next_call(...)`, Hermes treats
that as middleware failure and continues with the remaining middleware chain
and base execution.
- If execution middleware calls `next_call(...)` successfully and then raises
during post-processing, Hermes preserves the downstream result and does not
run the provider or tool a second time.
- If downstream provider or tool execution fails, middleware may let that error
propagate or translate it deliberately. Hermes does not convert downstream
failure into a successful `None` result.
- Tool request middleware runs before approvals. If it mutates file paths,
commands, URLs, or arguments, the mutated values are what guardrails and
approvals evaluate.
- Observer hooks remain the right place for read-only telemetry. Use middleware
only when a plugin needs to alter or wrap behavior.
+325
View File
@@ -0,0 +1,325 @@
# Hermes Observer Hooks
Hermes observer hooks are the read-only telemetry contract for plugins that
need to reconstruct agent execution without changing runtime behavior. This
contract supports trace, metrics, audit, replay, and export integrations such
as Langfuse, OpenTelemetry-style collectors, and NeMo Relay.
Observer hooks are intentionally backend-neutral. They expose stable lifecycle
events, correlation IDs, sanitized payloads, timing, status, and error fields.
They do not replace Hermes' planner, model providers, memory, tool registry,
approval UX, CLI, gateway behavior, or execution semantics.
Behavior-changing request or execution wrappers are outside this observer
contract. Observer hooks should report what happened; they should not replace
provider requests, tool arguments, or execution callbacks.
Hermes also has a first-party NeMo Relay shared-metrics path. It uses these
lifecycle boundaries directly and does not require enabling an observability
plugin. See [Relay shared metrics](relay-shared-metrics.md).
## Contract
Plugins register observer callbacks from `register(ctx)`:
```python
def register(ctx):
ctx.register_hook("pre_api_request", on_pre_api_request)
ctx.register_hook("post_api_request", on_post_api_request)
ctx.register_hook("pre_tool_call", on_pre_tool_call)
ctx.register_hook("post_tool_call", on_post_tool_call)
```
Every hook callback receives keyword arguments. Plugins should accept
`**kwargs` so additive fields remain backward-compatible:
```python
def on_post_tool_call(**kwargs):
tool_name = kwargs.get("tool_name")
status = kwargs.get("status")
result = kwargs.get("result")
```
The plugin manager injects this field into every hook payload:
```text
telemetry_schema_version = "hermes.observer.v1"
```
Hook callbacks are fail-open. Hermes catches callback exceptions, logs a
warning, and keeps the agent loop running.
Most observer hook return values are ignored. The exceptions are older
behavior-affecting hooks:
| Hook | Return behavior |
| --- | --- |
| `pre_llm_call` | May return a string or `{"context": "..."}` to inject ephemeral context into the current user message. |
| `pre_tool_call` | May return `{"action": "block", "message": "..."}` to block a tool before execution, or `{"action": "modify", "args": {...}}` to transform the tool's input arguments. |
| `transform_tool_result` | May return a replacement tool result string after `post_tool_call`. |
| `transform_llm_output` | May return a replacement final assistant text string. |
Telemetry plugins should treat these behavior-affecting returns as optional
compatibility features, not as observability requirements.
## Correlation IDs
Observer payloads use stable IDs so plugins can join events without relying on
callback order alone.
| Field | Meaning |
| --- | --- |
| `session_id` | Conversation/session identity. |
| `task_id` | Task identity, especially useful for subagents and isolated execution. |
| `turn_id` | User-turn identity shared by API attempts and tool calls in a turn. |
| `api_request_id` | Opaque provider-attempt identity. Do not parse its string format. |
| `api_call_count` | Numeric API attempt count within the agent loop. |
| `tool_call_id` | Provider-supplied tool call ID when available. |
| `parent_session_id` / `child_session_id` | Session link for delegated subagents. |
| `parent_subagent_id` / `child_subagent_id` | Subagent link when available. |
| `parent_turn_id` | Parent turn that spawned delegated work. |
Consumers should prefer explicit fields over parsing compound IDs. In
particular, `api_request_id` is an opaque correlation value.
## Event Families
### Session Lifecycle
Session hooks describe conversation boundaries and resets:
| Hook | When it fires |
| --- | --- |
| `on_session_start` | A brand-new session starts after the system prompt is built. |
| `on_session_end` | A `run_conversation` call ends, including interrupted or incomplete turns. |
| `on_session_finalize` | CLI or gateway tears down an active session identity. |
| `on_session_reset` | CLI or gateway moves from an old session identity to a new one. |
Common fields include `session_id`, `completed`, `interrupted`, `reason`,
`old_session_id`, and `new_session_id` where available.
`on_session_end` is turn/run scoped. It is not necessarily the final lifetime
boundary for a chat identity. Use `on_session_finalize` and `on_session_reset`
for lifecycle cleanup that must happen once per session identity.
### Turn-Scoped LLM Hooks
These hooks frame the user turn, not individual provider API attempts:
| Hook | When it fires |
| --- | --- |
| `pre_llm_call` | Before the tool loop begins for a user turn. |
| `post_llm_call` | After the turn completes with final assistant output. |
Common `pre_llm_call` fields include `session_id`, `turn_id`,
`user_message`, `conversation_history`, `is_first_turn`, `model`, `platform`,
and `sender_id`.
Common `post_llm_call` fields include `session_id`, `turn_id`,
`user_message`, `assistant_response`, `conversation_history`, `model`, and
`platform`.
Use request-scoped API hooks for LLM span telemetry. Use `pre_llm_call` and
`post_llm_call` for turn-level context, compatibility, and final turn summary.
### Request-Scoped API Hooks
API hooks describe provider attempts inside the agent loop:
| Hook | When it fires |
| --- | --- |
| `pre_api_request` | Immediately before a provider API request. |
| `post_api_request` | After a successful provider response. |
| `api_request_error` | After a failed provider request or retryable error path. |
`pre_api_request` includes:
- identity: `session_id`, `task_id`, `turn_id`, `api_request_id`
- runtime: `platform`, `model`, `provider`, `base_url`, `api_mode`
- attempt metadata: `api_call_count`, `message_count`, `tool_count`,
`approx_input_tokens`, `request_char_count`, `max_tokens`
- timing: `started_at`
- sanitized request payload: `request`
`post_api_request` includes the same identity/runtime fields plus:
- `api_duration`, `started_at`, `ended_at`
- `finish_reason`, `message_count`, `response_model`
- `usage`
- `assistant_content_chars`, `assistant_tool_call_count`
- sanitized response payload: `response`
- compatibility object: `assistant_message`
`api_request_error` includes the same identity/runtime fields plus:
- `api_duration`, `started_at`, `ended_at`
- `status_code`, `retry_count`, `max_retries`, `retryable`, `reason`
- structured `error = {"type": ..., "message": ...}`
- sanitized failed request payload: `request`
The sanitized `request`, `response`, and `error` fields are the canonical
observer inputs for new consumers.
### Tool Lifecycle
Tool hooks describe individual tool calls:
| Hook | When it fires |
| --- | --- |
| `pre_tool_call` | Before guardrail-approved tool dispatch. |
| `post_tool_call` | After tool dispatch, cancellation, block, or error completion. |
| `transform_tool_result` | After `post_tool_call`, before the result is appended to model context. |
`pre_tool_call` includes `tool_name`, `args`, `task_id`, `session_id`,
`tool_call_id`, `turn_id`, and `api_request_id`.
`post_tool_call` includes the same identity fields plus `result`,
`duration_ms`, `status`, `error_type`, and `error_message`.
`status` is the observer-grade lifecycle outcome. Common values include:
| Status | Meaning |
| --- | --- |
| `ok` | Tool completed normally. |
| `error` | Tool ran and returned or raised an error outcome. |
| `blocked` | A `pre_tool_call` hook blocked execution. |
| `cancelled` | Execution was cancelled before normal completion. |
`post_tool_call` is emitted for blocked and cancelled paths so telemetry
plugins can close spans cleanly.
### Approval Lifecycle
Approval hooks describe dangerous-command approval prompts:
| Hook | When it fires |
| --- | --- |
| `pre_approval_request` | Before the approval request is shown or sent. |
| `post_approval_response` | After the user responds or the request times out. |
Common fields include `command`, `description`, `pattern_key`,
`pattern_keys`, `session_key`, and `surface`.
`post_approval_response` also includes `choice`, with values such as `once`,
`session`, `always`, `deny`, and `timeout`.
Approval hooks are observer-only. Plugins cannot pre-answer or veto approvals
from these hooks. To prevent a tool from reaching approval, use
`pre_tool_call` blocking.
### Subagent Lifecycle
Subagent hooks describe delegated child-agent work:
| Hook | When it fires |
| --- | --- |
| `subagent_start` | A delegated child agent is created. |
| `subagent_stop` | A delegated child agent returns or fails. |
`subagent_start` fields include `parent_session_id`, `parent_turn_id`,
`parent_subagent_id`, `child_session_id`, `child_subagent_id`, `child_role`,
and `child_goal`.
`subagent_stop` fields include parent/child session IDs, role/status fields,
`child_summary`, `duration_ms`, and a metadata-only `tool_call_history`. Each
history entry contains the tool name, argument names, bounded side-effect
targets, input/output byte counts, and outcome. URL query strings and fragments
are removed; raw arguments, prompts, commands, contents, headers, and results
are intentionally excluded.
Observers can use these hooks to model nested trajectories while keeping child
agent execution linked to the parent turn that spawned it.
## Payload Safety
Observer payloads are designed for telemetry consumers, not raw object access.
New consumers should use the sanitized API payloads:
- `pre_api_request.request`
- `post_api_request.response`
- `api_request_error.request`
- `api_request_error.error`
Sanitization converts provider objects to JSON-compatible structures, bounds
large payloads, redacts sensitive keys, and avoids exposing raw response
objects in sanitized fields.
Legacy compatibility fields such as `request_messages`, `conversation_history`,
and `assistant_message` may still be present for existing plugins. New
observability consumers should prefer the sanitized payloads.
## Performance
The default uninstrumented path should stay cheap. Expensive request/response
payload construction is gated behind `has_hook(...)`, so Hermes only builds
sanitized API telemetry payloads when at least one plugin registered the
relevant hook.
Plugin authors should preserve this property:
- Register only hooks the plugin actually consumes.
- Avoid deep-copying or re-sanitizing already sanitized payloads.
- Keep hook callbacks fast and fail-open.
- Offload network export or batch writes when practical.
## Writing An Observer Plugin
Minimal observer plugin:
```python
def register(ctx):
ctx.register_hook("pre_api_request", on_pre_api_request)
ctx.register_hook("post_api_request", on_post_api_request)
ctx.register_hook("pre_tool_call", on_pre_tool_call)
ctx.register_hook("post_tool_call", on_post_tool_call)
def on_pre_api_request(**kwargs):
start_llm_span(
request_id=kwargs.get("api_request_id"),
turn_id=kwargs.get("turn_id"),
request=kwargs.get("request"),
model=kwargs.get("model"),
)
def on_post_api_request(**kwargs):
finish_llm_span(
request_id=kwargs.get("api_request_id"),
response=kwargs.get("response"),
usage=kwargs.get("usage"),
duration=kwargs.get("api_duration"),
)
def on_pre_tool_call(**kwargs):
start_tool_span(
call_id=kwargs.get("tool_call_id"),
name=kwargs.get("tool_name"),
args=kwargs.get("args"),
)
def on_post_tool_call(**kwargs):
finish_tool_span(
call_id=kwargs.get("tool_call_id"),
result=kwargs.get("result"),
status=kwargs.get("status"),
duration_ms=kwargs.get("duration_ms"),
)
```
Use `session_id`, `turn_id`, `api_request_id`, and `tool_call_id` for span
correlation. Use subagent and approval hooks when the export format supports
nested agent work or security lifecycle events.
## Existing Consumers
The bundled Langfuse plugin demonstrates direct hook-based observability for
turns, provider requests, and tool calls.
The native NeMo Relay SDK integration maps Hermes session, turn, LLM, and tool
lifecycles to Relay. Explicit Relay plugin configuration can add
[ATOF, ATIF, or OTEL](https://docs.nvidia.com/nemo/relay/configure-plugins/observability/about)
exporters and execution middleware; see
[Relay shared metrics](relay-shared-metrics.md).
+304
View File
@@ -0,0 +1,304 @@
# Gateway Monitoring
Service health monitoring plus structured operational diagnostics for the
Hermes gateway daemon, exported over OTLP/HTTP to an operator-configured
endpoint (OpenTelemetry Collector, DataDog, or any OTLP receiver).
This plane is content-free by construction. It exports gateway and cron
lifecycle state, platform connector health, and content-free warning/error
diagnostics. It never exports prompts, messages, tool arguments or results,
job names, destinations, schedules, raw errors, session history, usage
analytics, audit logs, or detailed execution traces. Run/model/tool trajectory
capture is a separate plane served by Hermes's native NeMo Relay SDK
integration and explicitly configured Relay subscribers or exporters.
## What gets exported
| Signal | OTLP route | Content |
| --- | --- | --- |
| Gateway gauges | `/v1/metrics` | `hermes.gateway.up/state/busy/drainable/active_agents/background_work/background_delegations/restart_requested`, `hermes.platform.up/degraded` with bounded `error_code` attributes |
| Health/lifecycle events | `/v1/traces` | `gateway.lifecycle` state transitions (`starting -> running -> draining -> stopped`, `startup_failed`, exit), `gateway.health_snapshot`, platform state changes |
| Diagnostics | `/v1/logs` | Warning/error gateway events with a constant body and bounded subsystem, severity, error class, and error code attributes; rendered log messages are never exported |
| Cron scheduler gauges | `/v1/metrics` | Ticker heartbeat and last-success age (omitted when unavailable), a monotonic catch-up-occurrence count from the scheduler's stale-window branch, enabled/running job counts, and overdue count derived from persisted `next_run_at` plus the scheduler's existing grace rule |
| Cron execution lifecycle | `/v1/traces` | Durable `claimed/running/completed/failed/unknown` states, bounded source and error class, opaque hashed job key, elapsed duration when timestamps exist, and delivery outcome when the scheduler knows it; terminal states make a fail-open flush attempt that can delay completion by up to one second |
Signals carry `service.name`, version, supervision mode, and a stable one-way
hash of the install id so an operator can distinguish instances without
exporting account/profile identity or the raw install identifier.
`hermes.gateway.active_agents`, `hermes.gateway.background_work`, and
`hermes.gateway.background_delegations` are complementary. `active_agents`
counts foreground message turns plus in-flight cron jobs plus API runs — the
work the gateway drains on shutdown. `background_work` counts detached work that
`active_agents` never includes: backgrounded `delegate_task` subagents,
`terminal(background=true)` processes, and kanban workers; it is
**task-granular** — a fan-out batch of N subagents counts as N — so it reflects
real concurrent subagent load. `background_delegations` counts only async
delegation **units** (each `delegate_task` dispatch is one, a fan-out batch is
one), matching the async pool's capacity accounting; alert it against
`delegation.max_concurrent_children` to see slot pressure. Sum `active_agents`
and `background_work` for total live work per instance; use
`background_delegations` for pool-saturation.
## Enabling
```yaml
# config.yaml
monitoring:
gateway_health_export:
enabled: true
export:
otlp:
enabled: true
endpoint: http://collector-host:4318/v1/traces # metrics/logs derive
headers_env: {} # header name -> ENV VAR NAME (values never stored)
```
Check the posture any time:
```bash
hermes monitoring status
```
The OpenTelemetry SDK is an optional extra (`pip install 'hermes-agent[otlp]'`),
lazy-installed on first use. When the SDK is missing or the endpoint is down,
the gateway runs unaffected: metric collection and ordinary event export stay
off the hot path, while terminal cron events make one bounded fail-open flush
attempt of up to one second so the final state is less likely to be lost.
Works identically under systemd/launchd/s6 supervision, containers, tmux, or
a plain `hermes gateway run`: the exporter lives in the gateway process, so
no sidecar, agent, or collector is required on the host.
## Collecting into DataDog
Run a customer-owned OpenTelemetry Collector and forward:
```yaml
# otel-collector config
receivers:
otlp:
protocols:
http:
exporters:
datadog:
api:
key: ${env:DD_API_KEY}
service:
pipelines:
metrics: {receivers: [otlp], exporters: [datadog]}
traces: {receivers: [otlp], exporters: [datadog]}
logs: {receivers: [otlp], exporters: [datadog]}
```
Point `monitoring.export.otlp.endpoint` at the collector. Alerts belong on
`hermes.gateway.up`, `hermes.platform.up`, and `hermes.platform.degraded`.
## Generic fleet queries and alerts
The exact syntax depends on the customer's observability backend. The examples
below use PromQL-style expressions and intentionally avoid vendor-specific
routing, destinations, or customer inventory.
Group fleet views by the opaque `service.instance.id` resource attribute. A
process that has died cannot emit its own zero, so every deployment needs both
explicit-state and missing-series detection.
```promql
# Explicit gateway failure.
hermes_gateway_up == 0
# Box disappeared or stopped exporting. Choose a window longer than the
# configured export interval and collector retry allowance.
absent_over_time(hermes_gateway_up[5m])
# Locally owned bridge is explicitly down.
hermes_platform_up == 0
# Scheduler thread is stale even though the gateway may still be alive.
hermes_cron_scheduler_heartbeat_age_seconds > 180
# Ticker loops but has not completed a successful tick recently.
hermes_cron_scheduler_last_success_age_seconds > 300
# One or more jobs are beyond their existing scheduler grace window.
hermes_cron_jobs_overdue > 0
# Catch-up counter increased, proving at least one stale occurrence was
# collapsed and run once after a delay.
increase(hermes_cron_scheduler_catch_up_occurrences[15m]) > 0
```
Cron execution lifecycle records arrive as `hermes.cron_execution` spans.
Alert or derive events from bounded attributes such as:
```text
hermes.status = failed|unknown
hermes.delivery_outcome = failed|not_configured
hermes.error_class = auth_failed|rate_limited|timeout|network_error|
dispatch_failed|interrupted|empty_response|
invalid_config|unknown
```
Recommended operator views:
1. one row per `service.instance.id` with gateway and configured local-platform
state;
2. scheduler heartbeat, last-success age, running count, overdue count, and
catch-up increase;
3. a cron lifecycle feed keyed only by opaque `hermes.job_key`;
4. separate alerts for box absence, local bridge down, scheduler stale, cron
failed/unknown, delivery failure, and overdue/catch-up activity.
Keep alert thresholds and routing in deployment-owned configuration. Do not add
job names, prompts, outputs, schedules, destinations, raw errors, profile names,
or account identity merely to make a dashboard easier to read.
## Release-validation scenarios
Before accepting a deployment, force and verify all five cases through the real
collector and backend:
1. **Cron success:** observe `claimed -> running -> completed`, duration, and a
truthful delivery outcome.
2. **Cron failure:** observe `failed` plus a bounded error class, with no raw
exception or content in the decoded OTLP payload.
3. **Cron interruption:** stop the owning gateway during execution, restart it,
and observe recovery to `unknown`.
4. **Locally owned bridge outage:** break one native connector, observe its
bounded down/retrying/fatal state and recovery, and verify unaffected boxes
remain healthy.
5. **Killed gateway:** terminate one canary, verify missing-series detection,
restart it, and confirm the same opaque instance identity returns.
Hermes Agent-owned Relay transport health remains in scope. A separate gateway
or connector service remains authoritative for any shared connected-platform
state that it owns and should export that state through its own telemetry path.
For every scenario, verify the signal and alert clear on recovery, other boxes
remain unaffected, collector failure stays fail-open, and decoded metrics,
spans, logs, and resource attributes remain content-free.
## Local smoke test (no Docker)
```bash
# terminal 1: capture collector on :4318
python scripts/observability/otel_capture_collector.py \
--host 127.0.0.1 --port 4318 --log /tmp/hermes_otel_capture.jsonl
# terminal 2: drive the real exporter through lifecycle transitions,
# a fatal platform, and a structured warning event, then flush
python scripts/observability/gateway_health_export_probe.py \
--endpoint http://127.0.0.1:4318/v1/traces \
--log /tmp/hermes_otel_capture.jsonl --wait 8
# exit 0 prints: {"requests": 6, "paths": ["/v1/logs", "/v1/metrics", "/v1/traces"]}
```
## Maintaining and extending this plane
This plane is a **fixed, enumerated, content-free vocabulary** by design. Adding
a signal is not just "emit a new metric" — every new name and attribute must be
declared in each layer that enforces the bounded vocabulary, or it is silently
dropped downstream. Follow the checklist for the change you are making. The
golden rule: **a new signal that is emitted but not declared in every layer
looks like a code bug but is a vocabulary-registration bug — nothing errors, the
signal just never arrives.**
### Content-free invariant (applies to every change)
Before adding anything, confirm it cannot carry content. Numbers, booleans,
ages, durations, monotonic counts, and one-way hashes are safe. **Never** add an
attribute that can hold a job name, prompt, output, schedule, destination, raw
exception text, file path, profile name, account id, or free-form string. When
you must key a record to a job/entity, hash it (`sha256(...)[:24]`, see
`_job_key` in `agent/monitoring/cron_health.py`) — never emit the raw id. All
string attributes that could touch user input must pass through
`redaction.redact_for_export` and be truncated (see `_span_attrs` in
`agent/monitoring/otlp_exporter.py`).
### Adding a new gauge/metric
1. Emit it in the snapshot builder (`agent/monitoring/gateway_health.py`
`build_gateway_health_snapshot`, `cron_health.py` `build_cron_health_snapshot`,
or a sibling reader wired into `_read_runtime_snapshot` in
`gateway_health_export.py`). Best-effort: never let a reader raise into the
collection loop — wrap it and log a **content-free WARNING with the exception
TYPE name only** (the pattern the cron and background-work readers use), so a
future regression is visible instead of silently dropping the signal.
2. Register the dotted metric name in the observable-gauge `metric_names` list in
`gateway_health_export.py::_start_metric_provider`. **A gauge that is emitted
in the snapshot but not registered here is never observed.**
3. Add the export-table row and an alert example in this file.
4. If the deployment fronts the exporter with an OpenTelemetry Collector that
uses a metric-name allowlist (a `filter/...` processor with `name != "..."`
guards), add the new name there too — otherwise the collector drops it before
the backend. This is not repo code, but it is the single most common reason a
correctly-emitted new metric never appears; call it out in the PR so the
deploying operator updates their collector config.
### Adding a new subsystem (a new family of signals)
Mirror the cron pattern (`cron_health.py` + its wiring): put the read/projection
logic in its own module, expose one `build_<subsystem>_health_snapshot()` that
returns bounded `GatewayMetric`s (and events if any), and extend it into
`_read_runtime_snapshot` with the same best-effort try/except-WARNING guard.
Then do the "adding a metric" checklist for each new name, and the "adding an
attribute" checklist for each new event attribute. Add a release-validation
scenario below for the subsystem's failure mode.
### Extending the error-class / status / source / state vocabularies
These are the closed enums that keep the plane bounded. Extend the SET, then the
classifier, never one without the other:
- **Cron** (`agent/monitoring/cron_health.py`): `_KNOWN_STATUSES`,
`_KNOWN_SOURCES`, `_KNOWN_DELIVERY_OUTCOMES`, and the `classify_cron_error`
keyword buckets. Anything not in the set is coerced to `unknown` on the way
out, so a new value that is not added to the set is invisible.
- **Gateway/platform** (`agent/monitoring/gateway_health.py`):
`_KNOWN_GATEWAY_STATES`, `_KNOWN_PLATFORM_STATES`, and `classify_gateway_error`.
Rules: keep the vocabulary SMALL and operationally meaningful (an error class
should map to an operator action, not to an exception subclass); a new bucket
must match on a stable keyword, not on message text that could vary; update the
`hermes.error_class = ...` list in this file's alert section and the enum's unit
test so the contract is asserted, not frozen as a count.
### Adding a content-free attribute to an existing event/span
Add the key to the emitter's per-kind `keep_by_kind` allowlist in
`agent/monitoring/otlp_exporter.py::_span_attrs` (unlisted keys are dropped), run
it through redaction if it is ever string-shaped, and — as with metrics — if the
deployment's collector has a span-attribute `keep_keys(...)` allowlist, add the
attribute there too or it is stripped in transit.
### Verify the whole chain, not just emission
Emitting is necessary but not sufficient. Confirm the signal survives all the
way to the backend, because the enums, the `metric_names` registration, the
emitter attribute allowlist, and any collector allowlist each drop unlisted
values with no error:
```bash
hermes monitoring status # posture
python scripts/observability/gateway_health_export_probe.py \
--endpoint http://127.0.0.1:4318/v1/traces \
--log /tmp/cap.jsonl --wait 8 # drive the real exporter
```
Decode the captured OTLP payload and assert the new name/attribute is present
AND that no content leaked. When a real collector sits in front, add its
allowlist entries and re-verify against the backend, not just the local capture.
## Boundaries and roadmap
The `hermes monitoring` CLI intentionally exposes `status` only. This first
release covers only Hermes Agent-owned service-health and operational-diagnostic
signals, including Hermes Agent-owned Relay transport health. Team Gateway's
authoritative shared connector/platform state is explicitly out of scope, as
are product analytics, audit/quality reporting, and detailed execution traces.
Shared client usage metrics and enterprise trace telemetry are being designed on
the NeMo Relay integration with their own consent, policy, and export
boundaries; this monitoring plane stays narrow so an operator can enable it
without touching any content-bearing signal. The telemetry surface may be
reorganized as that lands.
+482
View File
@@ -0,0 +1,482 @@
# NeMo Relay Shared Metrics
Hermes includes NeMo Relay as a normal runtime dependency on platforms for
which Relay publishes a native wheel. The shared-metrics integration is built
into Hermes and does not require a Hermes observability plugin. Hermes remains
importable without Relay on other native targets. Those targets use an
explicit reduced-capability no-op host:
Hermes execution remains available, while Relay scopes, middleware, plugins,
and subscribers are unavailable. The `hermes-agent[nemo-relay]` extra remains
as a no-op compatibility alias for existing installation commands.
> [!WARNING]
> This removes the Hermes `observability/nemo_relay` plugin. Existing users
> must remove `observability/nemo_relay` (or its legacy `nemo_relay` alias)
> from `plugins.enabled` and move exporter configuration into a Relay
> `plugins.toml` selected with `HERMES_NEMO_RELAY_PLUGINS_TOML`. The legacy
> `HERMES_NEMO_RELAY_ATOF_*` and `HERMES_NEMO_RELAY_ATIF_*` variables no
> longer activate exporters. Without the new variable, Hermes does not run
> Relay plugin discovery, configuration layering, middleware, or exporters.
Hermes requires NeMo Relay 0.8.3 or later within the 0.8 release line. That
line provides the provider-codec and canonical tool-result contracts Hermes
uses for managed provider and tool calls.
## Runtime Dependency and Data Boundary
Hermes installs the platform-specific `nemo-relay` native wheel from the
bounded `>=0.8.3,<0.9` dependency range. The published package is built from
the [NVIDIA NeMo Relay repository](https://github.com/NVIDIA/NeMo-Relay).
Unsupported platforms use the explicit no-op runtime described above rather
than downloading a different implementation.
Operator-supplied typed native plugins must be rebuilt for Relay 0.8. `grpc-v1`
workers must be regenerated and rebuilt when they use tool callbacks, tool
execution intercepts, or manual tool-end APIs.
When Relay managed execution is active, the provider request and response pass
through that native module in the Hermes process so configured interceptors can
operate on the real call. This is separate from the shared-metrics data
contract. Shared-metrics mode installs no rich-observability network exporter,
and its subscriber
accepts only the versioned, allowlisted projection described below. The
opt-in package sender described in Appendix A is the only outbound path, it
transmits nothing unless the user enables both `enabled` and `send`, and it
sends whole packages rather than live spans. Enabling a
separately configured rich-observability or dynamic plugin can create a
different data path and requires its own policy review.
Collection remains off unless Hermes policy enables it:
```yaml
telemetry:
shared_metrics:
enabled: true
```
This choice is read from the profile's own `config.yaml`. A machine-managed
configuration overlay cannot enable or disable shared metrics on the profile's
behalf.
Relay plugin activation is owned by the native runtime and remains explicitly
opt-in. Set `HERMES_NEMO_RELAY_PLUGINS_TOML` to a selected `plugins.toml` to
activate configured middleware, exporters, or dynamic plugins. When the
variable is unset, Hermes does not invoke Relay's plugin initializer, so Relay
does not perform plugin configuration discovery or layering. When it is set
and the selected file loads successfully, Relay discovers supported user and
system `plugins.toml` files and layers the selected static configuration over
them. Repository-local `.nemo-relay/plugins.toml` files are ignored. Dynamic
`[[plugins.dynamic]]` records are loaded from the selected file only. If the
selected file cannot be loaded, Hermes reports the error and does not invoke
Relay initialization or fall back to ambient discovery.
## Session-Span Segmentation for Continuous Sessions
Relay exports a span when its scope closes. A continuous gateway session can
remain open for days, so its session span remains open even though each turn
span is exported normally. Optional segmentation rotates only the session
scope at a turn boundary:
```yaml
gateway:
telemetry:
session_segments:
on_compaction: false # rotate after context compaction
max_turns: 0 # 0 = unlimited; N = turns per segment
```
| Key | Default | Behavior |
|---|---:|---|
| `on_compaction` | `false` | Rotate after compaction completes, at the next turn boundary. |
| `max_turns` | `0` | Rotate after every N completed turns; `0` disables the cap. |
Both defaults preserve one session scope for the full session. Rotated spans
retain the same `session_id` and add `hermes.session.segment` plus
`hermes.session.segment_reason` (`compaction` or `max_turns`).
## Process-Wide Plugin Policy and Profile Isolation
Relay plugin configuration is a process-level deployment choice, not a Hermes
profile setting. The first hosted profile triggers lazy initialization, and
every additional profile hosted by that Hermes process shares the resulting
static middleware, dynamic plugins, subscribers, exporters, and guardrail
policy. After initialization succeeds, Hermes logs:
```text
Relay plugins are active process-wide and apply to all profiles hosted by this Hermes process.
```
Profile scopes still preserve causal isolation inside that shared policy.
ATIF groups events by their top-level Agent scope, so simultaneous profile
sessions produce separate trajectories rather than one mixed trajectory.
ATOF and other global subscribers observe events from every hosted profile.
Static and dynamic middleware likewise runs for managed calls from every
profile.
A worker plugin running in a separate worker process does not create a
per-profile security boundary. One process-wide activation dispatches calls
from all hosted profiles to that worker while preserving the invoking
profile's Relay scope stack. Native dynamic plugins are loaded into the Hermes
process and share the same policy boundary.
Run profiles in separate Hermes processes when they require different trust
levels, plugin credentials, exporter destinations, or guardrail policies.
This process-wide plugin contract does not change each profile's independent
shared-metrics consent, local SQLite state, or ATIF trajectory grouping.
Hermes core owns one Relay host and one isolated Relay session scope per Hermes
session. Core lifecycle producers use
`hermes_cli.observability.relay_runtime` to obtain the shared session handle or
run Relay scope, LLM, tool, and mark APIs in that session context. New product
marks do not require Hermes plugin registration. Shared-metrics marks must
still contain only fields approved by the versioned allowlist; the hard
dependency does not change the collection or privacy policy.
## Current Slices
The current vertical slices record pseudonymous profile activity, logical
model calls, top-level task runs, tool and approval outcomes, and skill
lifecycle and reuse:
```text
Hermes turn, API, tool, and approval hooks
-> Relay session, task, LLM, tool, and mark lifecycle
-> Hermes shared-metrics subscriber
-> SQLite counters
-> immutable JSON delta package
```
Hermes sends an empty `LLMRequest` into the metrics-owned lifecycle. This does
not describe the separate managed-execution call through the native runtime
documented above. The terminal metrics event contains the model identifier and
provider route that Hermes used for the logical call, such as
`nvidia/nemotron-3-ultra` through `openrouter`. These identifiers are
lowercased and structurally bounded, but they are not normalized through a
checked-in model catalog. Pricing and model-family classification belong to
the metrics backend. Prompts, responses, endpoints, errors, session IDs, task
IDs, and request IDs are not included in the metrics event or package.
New calls use `hermes.model_route.count`. The previous
`hermes.model_call.count` contract remains readable only so pending local
counters created by older builds can be exported without losing data.
The first consented session start emits an empty `hermes.client.active` Relay
mark. The profile-scoped subscriber creates a random UUID install identity and
uses a transactional compare-and-set to record at most one client-active
counter in any rolling 24-hour window. The metric has no dimensions; Hermes
version, OS family, architecture, and install method remain bounded package
resources. Concurrent Hermes processes share the SQLite latch, so simultaneous
starts cannot double-count one install. A later session or task can attempt the
mark again, but the subscriber suppresses it until the rolling window expires.
Each task run is a Relay `Function` scope named `hermes.task_run`, parented to
the owning Hermes session. The start counter contains only bounded execution
surface and entrypoint values. The terminal counter contains bounded outcome,
end reason, termination status, duration, logical model-call count, terminal
tool-call count, and provider-retry count buckets. Retries are additional
provider attempts for the same Hermes API request ID; they do not inflate the
logical model-call count. Tool calls are deduplicated by their Hermes tool-call
ID after a terminal tool result is observed. The outer `AIAgent` execution
boundary closes the task for normal returns, early returns, exceptions, and
cancellations. Active task ownership follows the task ID if Hermes rotates its
conversation session during context compression.
Each tool invocation is represented by a Relay tool lifecycle named
`hermes.tool_call`. The terminal counter contains only bounded tool category,
outcome, approval outcome, latency, and explicit retry-count buckets. Hermes
derives the category from the toolset already declared in its runtime registry;
custom and unrecognized toolsets collapse to `other` rather than exporting
tool or plugin names. Hermes does not infer retries from repeated tool names or
adjacent calls; when the
hook does not provide an explicit retry relationship, the retry bucket is
`unknown`. Approval decisions are emitted as `hermes.tool_approval` marks and
recorded as attributed to a tool call or explicitly `unattributed`. Tool names,
call IDs, arguments, results, commands, descriptions, and error text are not
included in shared-metrics events or packages. A started tool that is still
open when its task terminates is closed as failed, timed out, or cancelled and
remains in the task's tool-count bucket.
Successful skill mutations emit `hermes.skill.lifecycle` marks with only a
bounded action and provenance. Successful loads emit `hermes.skill.load`
marks with bounded provenance, first-use or reuse state, reuse-after-patch
state, and a use-count bucket. Hermes derives reuse and patch-generation
continuity transactionally in its existing `skills/.usage.json` state; skill
names and exact counts or generations never enter Relay metrics events,
SQLite dimensions, or packages. A use after a new patch is counted once as
`reused_after_patch`; later uses remain ordinary reuse until another patch.
Task-outcome attribution after a patch remains deferred until its window and
multi-skill semantics are defined.
Local state is written under:
```text
$HERMES_HOME/telemetry/shared_metrics/metrics.sqlite3
$HERMES_HOME/telemetry/shared_metrics/outbox/*.json
```
The database keeps transactional aggregate and package-outbox state. Package
files are immutable delta documents that conform to a closed JSON schema and
are written with atomic replacement. Each package records the Hermes version,
OS family, architecture, and install method as bounded client resources.
Unrecognized platform or installation values are exported as `unknown`; raw
platform strings, hostnames, and paths are never included. Fully packaged
aggregate rows and successfully exported package rows and files are retained
locally for 30 days. Pending package rows and counters with unexported deltas
are never pruned.
Package schema v1 remains unchanged for existing outbox files. New packages
use v2, which accepts both the retired model-call contract and the current
model-route contract so upgrades can drain pending counters safely.
Each package contains an `install_id` generated as a random UUID. Despite the
schema field name, its current scope is one `HERMES_HOME`, so it is more
precisely a persistent pseudonymous profile identifier. It is not derived from
hardware, account, host, path, or credential data. It remains stable across
packages from that profile and can therefore link those local packages.
Deleting `$HERMES_HOME/telemetry/shared_metrics` resets the identifier together
with all aggregates and package files.
Remote delivery is opt-in and off by default. Reusing the persistent local
identifier remotely required a separate product and privacy decision covering
consent, identity scope, reset behavior, retention, and deletion — that
decision has been made.
> Those decisions are recorded in
> [Appendix A](#appendix-a-remote-exporter-decisions-phase-2), and the exporter
> implementing them has shipped. Collection alone still transmits nothing: the
> sender runs only when `telemetry.shared_metrics.send` is also true. Each
> transmitted package carries the stable `install_id` as-is (product decision,
> 2026-08-27 — see A.2 for the record, including the superseded
> HMAC-pseudonym design).
The install identity is scoped to one `HERMES_HOME`. To reset it, stop Hermes
processes and remove `$HERMES_HOME/telemetry/shared_metrics`. This deliberately
removes the old identity, aggregate database, and queued local packages
together; the next consented session creates a new identity. Disabling shared
metrics stops new collection but does not silently delete previously collected
local state.
## Smoke Test
Run a real Hermes CLI turn against the deterministic local model server:
```bash
./.venv/bin/python scripts/smoke_nemo_relay_shared_metrics.py
```
The script uses the installed `nemo-relay` dependency by default. Pass
`--relay-python ../nemo-relay/python` only when testing a locally built Relay
binding.
The smoke has the local model request a real `read_file` tool call before its
final response, then drives create, load, reuse, patch, edit, stale, archive,
restore, and install skill transitions through the installed Relay binding. It
verifies model, provider, task, tool, and skill counters in SQLite, validates
all exported delta packages against the closed schema, verifies the
pseudonymous client-active counter, and checks that prompt, response, tool-call
ID, tool-result, and skill-name canaries are absent from the packages.
## Appendix A: Remote Exporter Decisions (Phase 2)
Status: **implemented.** This appendix answers the product and
privacy questions that "Current Slices" defers to a future remote exporter. It
records what was decided and why, so the reasoning survives the implementation.
Sending is off by default and requires both `telemetry.shared_metrics.enabled`
and `telemetry.shared_metrics.send`.
The exporter sends the package files already written under
`$HERMES_HOME/telemetry/shared_metrics/outbox/` to the Hermes telemetry ingest
service. That service validates only the envelope (`schema_version` plus a UUID
`package_id`) and stores the body verbatim in S3.
### A.1 Consent
Transmission is a **separate opt-in** from collection, under a new config key:
```yaml
telemetry:
shared_metrics:
enabled: false # collect locally
send: false # NEW: transmit to the Nous telemetry service
```
- `send` defaults to **false**. Collection alone never transmits.
- `send` requires `enabled`. It does **not** imply it: a transmission flag must
not silently switch on collection. `send: true` with `enabled: false` warns
and does nothing.
- Like `enabled`, `send` is profile-owned and is not overridden by
managed-scope configuration.
**A package is only sent when its whole period falls inside a recorded
consent window.** Consent is stored as explicit intervals in the shared-
metrics SQLite store (`send_consent_windows`): a window opens when `send:
true` is first observed, is confirmed forward by every later observation,
and closes — at the last *confirmed* moment, never at the wall clock — when
`send: false` is observed. A single reconciler derives this table from the
config on every process start, so wizard changes, hand-edits to
`config.yaml`, and mid-pass revocations all take the same path, and no
transition can be missed by any of them.
Any package whose period predates the first window, falls between windows,
or runs past the newest confirmed moment is excluded — the gate fails
closed. A fresh package therefore waits at most one process start after its
period completes before becoming eligible.
The gate is on the **period**, not on the package's creation time. One period
is split across several packages created on different days: a day's first
package is written that day, and a tail package for the same period typically
follows the next day. Gating on creation time would send a period's tail while
dropping its head, reporting a **silently undercounted** day. Gating on the
period keeps consent forward-only and every transmitted period complete.
Local history can be up to 30 days old, and that data was collected under a
promise that nothing is uploaded. Honouring consent forward-only costs at most
30 days of backlog we never had permission to send.
### A.2 Identity scope — the stable install_id is transmitted as-is
**Decision record.** The original design of this exporter (and revisions 18
of this appendix) transmitted a keyed pseudonym instead of the identifier:
`HMAC-SHA256(key = locally-held rotating salt, message = install_id)`, with
the salt rotating every 30 days. On **2026-08-27**, before the feature
shipped (zero consented users, zero production transmissions), the product
owner decided the analytical need is a **stable cross-window identity**
retention curves, longitudinal install behaviour — which rotation by design
destroys. The pseudonymization layer was removed in full rather than
weakened in place.
What is transmitted now:
- Each package carries `install_id` verbatim: the persistent, profile-scoped
random UUID described above.
- It is generated locally (`uuid4`), contains no hardware, account, user, or
machine-derived information, and identifies a *profile*, not a person.
- It is stable until the user deletes the shared-metrics directory, which
regenerates it (see A.4).
Consequences stated plainly rather than papered over:
- Packages from one profile correlate **indefinitely**, not per-window.
Long-term linkability of one install's daily envelope sequence is now the
designed behaviour, not a residue.
- The A.3 residue analysis of the old design (stable `resource` tuple +
contiguous periods bridging rotation windows) is moot — there is no window
boundary left to bridge.
- The setup wizard's consent language states this identity model explicitly;
it was updated in the same change that removed the derivation, so no
consent was ever collected under the old wording in any shipped build.
**Byte-identical resends still hold.** The transmitted id is recorded on the
row (`sent_install_id`) when the package is first prepared, and the wire body
is always rebuilt from that recorded value, so a retry rebuilds identical
bytes. The contract requires this: resending a `package_id` with different
content is undefined behaviour. (With a stable id the recorded copy is no
longer load-bearing against rotation — it remains as the audit column and as
cheap insurance against any future change to identity semantics.)
### A.3 Rotation — removed (decision record)
Salt rotation was deleted together with the derivation (product decision,
2026-08-27). This section is retained as a record of what the earlier design
did and why the removal was accepted:
- Rotation existed to bound long-term linkability: one identity per 30-day
window, unrelated identities across windows.
- The documented residue (see git history for the full analysis): the
envelope's stable, low-entropy `resource` tuple plus contiguous daily
periods could plausibly bridge windows for rare configurations anyway, so
the boundary was a cost-raiser, not a wall.
- The product need that killed it: cross-window continuity is precisely what
retention analysis requires. A boundary that mostly inconveniences honest
analysis while only raising costs for a determined correlator was judged
the wrong trade once stable identity became a requirement.
There is no salt in the store, no rotation schedule, and no derived
identifier anywhere in the pipeline.
### A.4 Reset behavior
Removing `$HERMES_HOME/telemetry/shared_metrics` still resets local identity,
aggregates, and package files, exactly as documented above. Two honest
qualifications now apply:
- Reset regenerates `install_id`, so subsequent packages transmit a **new**
identity. Local reset does give a new remote identity.
- Reset **cannot unsend**. Packages already transmitted remain in the ingest
service's storage under the identifier they were sent with. There is no
read-back or delete API in the v1 contract.
Setting `send: false` stops transmission immediately: consent is re-read
before every package, so a pass already in flight stops after the package it
is currently sending rather than draining its whole batch. It does not delete
previously transmitted packages, and it does not stop local collection.
Turning sending off also **closes the consent window** — at the last moment
consent was actually observed, not at the wall clock. Packages whose periods
fall between one window and the next are never transmitted, even if sending
is later re-enabled, and this holds for any number of on/off cycles, across
hand-edits with no process running, and under a clock that jumps in either
direction (window opens are clamped above every timestamp already in the
store; observation marks advance by a bounded step per call, so one glitched
forward sample cannot drag the confirmation horizon years ahead; a close
never lands after the closing observation's own clock).
Unlike the earlier single moving opt-in date, closing and reopening does NOT
discard the still-undelivered backlog from a previous consented window —
those packages stay inside their own interval and remain eligible.
One deliberate upgrade-path consequence: packages exported under the
pre-interval consent model (before `send_consent_windows` existed) predate
the first recorded window and are therefore never transmitted after an
upgrade. This is the fail-closed direction — re-importing the old moving
day-stamp to release them would re-import the semantics five review rounds
showed to be unsound — and it costs at most the undelivered backlog, never
collected data.
### A.5 Retention
- **Local:** unchanged — 30 days for successfully exported history, and pending
deltas are kept until exported. Send state does **not** extend local
retention: a package that could never be sent is still pruned at 30 days.
Unbounded local growth against a permanently unreachable endpoint is a worse
failure than losing metrics from an install that has been broken for a month.
- **Remote:** raw packages are retained in S3 without expiry in production and
for 30 days in staging.
### A.6 Deletion
There is no remote deletion path in the v1 contract, and this appendix does not
invent one. What a user can do:
| Action | Effect |
|---|---|
| `send: false` | No further packages leave the machine |
| `enabled: false` | Collection stops; existing local state remains |
| Remove `.../shared_metrics` | Local identity, aggregates, and files reset; future sends use a new install_id |
| Delete already-sent data | Not self-service — requires an operator acting on the S3 bucket |
If a deletion-on-request obligation is ever taken on, the lookup path is now
direct: the user's `install_id` (readable from their local store) is the key
their data is stored under. Building the service-side delete API remains a
new product decision, not an implementation detail.
### A.7 What the outbox directory is
Recorded because it was misread once during Phase 2 planning, in a way that
would have deleted user data.
The directory is **local history, not a send-queue**. `package_outbox` is the
SQLite table; its `exported_at` column means "written to disk", not "sent".
Files are immutable and pruned **by age alone**.
The ingest contract says senders should delete a package from their outbox on
`202`. **The exporter does not do this.** Deleting on acknowledgement would
repurpose the user's 30-day local history as a transmission queue and destroy
state they were promised. Send state lives in new columns on the
`package_outbox` table instead; the files are untouched by transmission.
### A.8 Scope note
The `install_id` field inside the package body is transmitted as the
generator wrote it (rewritten from the row's frozen `sent_install_id`, which
records the same value). No other payload field changes, nothing is added,
and the service treats the whole body as opaque. Payload schema evolution
therefore stays a sender-side concern, as before.
+117
View File
@@ -0,0 +1,117 @@
# Profile-Based Routing for Inbound Messages
> **Audience:** Gateway operators and contributors
> **Source files:** `gateway/profile_routing.py`, `gateway/run.py` (`_profile_name_for_source`), `gateway/platforms/base.py` (`build_source`), `gateway/config.py`
> **Related:** [Session Lifecycle](session-lifecycle.md), `docs/design/profile-builder.md`
## Overview
By default a single gateway run uses one profile (memory, persona, tools). **Profile-based
routing** lets one gateway instance serve **multiple isolated profiles**, selecting which
profile handles an inbound message based on *where the message came from* — the platform,
server (`guild_id`), channel (`chat_id`), and/or thread (`thread_id`).
This is the inbound counterpart to multiplexing: instead of running N gateways, run one
gateway and route per-community / per-channel / per-thread to a dedicated profile. Each
profile keeps fully isolated state (`MEMORY.md`, `USER.md`, `SOUL.md`, sessions, tools).
Routing is **platform-generic**: it works for Discord, Telegram, Feishu, Slack, and every
adapter — not just Discord.
## Configuring routes
Routes live under `profile_routes` in `config.yaml`. Both the top-level and the nested
`gateway.profile_routes` forms are accepted (the nested form is what
`hermes config set gateway.profile_routes ...` writes).
```yaml
profile_routes:
# Route an entire Discord server (guild) to one profile.
- name: server-default
platform: discord
guild_id: "1234567890"
profile: server-profile
# Override a specific channel within that server with a different profile.
- name: support-channel
platform: discord
guild_id: "1234567890"
chat_id: "9876543210"
profile: support-profile
# Pin a Telegram group to a profile (Telegram has no guild_id — chat_id only).
- name: tg-group
platform: telegram
chat_id: "-1001234567890"
profile: tg-profile
# Route a single Discord thread.
- name: standup-thread
platform: discord
guild_id: "1234567890"
chat_id: "9876543210"
thread_id: "1111111111"
profile: standup
```
### Fields
| Field | Required | Description |
|---|---|---|
| `name` | yes | Human-readable route identifier (used in logs). |
| `platform` | yes | Adapter platform: `discord`, `telegram`, `feishu`, `slack`, … |
| `profile` | yes | Target profile name (must exist under `~/.hermes/profiles/<name>`). |
| `guild_id` | no | Server/guild (Discord). |
| `chat_id` | no | Channel/group/DM id. |
| `thread_id` | no | Thread id within a channel. |
| `enabled` | no | Default `true`; set `false` to disable a route without removing it. |
## Matching rules
A route matches an inbound source when **every discriminator the route declares is satisfied**
(conjunctive / AND). A field the route leaves unset is ignored.
- **`platform`** must equal the source platform exactly.
- **`thread_id`** (if set) must equal the source thread id.
- **`chat_id`** (if set) must match the source channel **or** its parent — a thread in a
channel matches the channel's route (hierarchical match for Discord forums/threads).
- **`guild_id`** (if set) must equal the source guild.
> A route declaring **both** `guild_id` and `chat_id` requires both to hold. A channel match
> alone does not satisfy a guild constraint — this is intentional and tested.
When multiple routes match, the **most specific** one wins. Specificity is additive:
| Discriminator | Weight |
|---|---|
| `thread_id` | 8 |
| `chat_id` | 4 |
| `guild_id` | 2 |
| (platform only) | 0 |
So a thread route (8) beats a channel route (4) beats a guild route (2) within the same server.
If no route matches, the message uses the default/active profile.
## How it works at runtime
1. An inbound message arrives at a platform adapter.
2. `BasePlatformAdapter.build_source` builds the `SessionSource` for the message. Every
adapter carries a back-reference to the running `GatewayRunner`
(`gateway_runner`, injected in `gateway/run.py`), so it asks the runner to resolve the
target profile via `_profile_name_for_source`.
3. `_profile_name_for_source` runs the configured routes through `match_profile_route` and
stamps `source.profile` with the winning route's profile (or leaves it unset).
4. Downstream, `_resolve_profile_home_for_source` chooses the profile home directory
(`source.profile` → active profile → `default`) and the session is scoped per-profile, so
each routed community gets isolated memory and conversation state.
Because `gateway_runner` is injected for **all** adapters (declared on `BasePlatformAdapter`),
every platform goes through this path — not just Discord.
## Relationship to multiplexing
`profile_routes` requires `gateway.multiplex_profiles: true`. Multiplexing is what
activates the per-profile runtime scope (per-profile `HERMES_HOME`, secret scope, and
profile-namespaced session keys); routing is the decision layer that picks *which*
profile a given guild/channel/thread lands in. With multiplexing off, `profile_routes`
is ignored entirely — behavior is byte-identical to a single-profile gateway.
+54
View File
@@ -0,0 +1,54 @@
# RCA: SSL CA cert bundle corruption after `hermes update`
**Status:** resolved by `fix(ssl): surface broken CA bundles before provider calls`
**Severity:** P2 — degrades the agent into opaque provider/client failures until the user repairs deps or CA configuration.
## Summary
A partial `hermes update`, interrupted venv repair, or stale CA-bundle environment variable can leave Python TLS configuration pointing at a missing, empty, or unloadable CA bundle. The first outbound HTTPS client creation or request can then fail with a raw `FileNotFoundError: [Errno 2] No such file or directory` or a low-level SSL error that does not name the broken CA path.
## Root cause
Hermes uses OpenAI/httpx and requests-based clients for provider calls, model metadata, gateway delivery, and web tools. Those clients inherit CA bundle settings from:
- `HERMES_CA_BUNDLE`
- `SSL_CERT_FILE`
- `REQUESTS_CA_BUNDLE`
- `CURL_CA_BUNDLE`
- the bundled `certifi` package's `cacert.pem`
When the venv is partially refreshed, or when one of those env vars points at a file that no longer exists, provider client construction can fail before Hermes has enough context to produce a useful message.
## Fix
`agent/ssl_guard.py` validates CA bundle configuration before the OpenAI-compatible provider client is created in `agent/agent_init.py`. It:
1. Checks explicit CA bundle env vars and reports the exact broken variable/path,
2. Verifies `certifi` is importable,
3. Verifies `certifi.where()` points at an existing file of plausible size,
4. Builds an `ssl.SSLContext` from each checked bundle,
5. Raises a typed `SSLConfigurationError` with a repair hint before httpx/OpenAI can raise a raw low-level error.
`hermes_cli doctor` exposes the same check under `SSL / CA Certificates`, so users can diagnose the problem without starting a model session.
## Recovery
When the guard fires during agent init, the user sees a message like:
```text
Failed to initialize OpenAI client: SSL_CERT_FILE points to a missing CA bundle: C:\path\to\missing\cacert.pem
Repair: python -m pip install --force-reinstall certifi openai httpx
If you configured a custom corporate CA bundle, fix or unset the broken CA bundle environment variable.
```
For a normal corrupted Hermes venv, reinstall the affected client dependencies:
```bash
python -m pip install --force-reinstall certifi openai httpx
```
For a custom/corporate CA setup, fix the env var so it points at a real PEM bundle, or unset it if Hermes should use the bundled `certifi` store.
## Environment escape hatch
Set `HERMES_SKIP_SSL_GUARD=1` to bypass the preflight check. This is intended only for sandboxed or managed-trust environments where the Python CA path looks unusual but downstream clients are known to work.
+786
View File
@@ -0,0 +1,786 @@
# Relay ↔ Connector Contract (v1, EXPERIMENTAL)
> **Status:** EXPERIMENTAL. This contract MAY CHANGE without a deprecation
> cycle until at least two real Class-1 platforms (Discord + Telegram) have
> validated it. Evolution during the experimental phase is **additive-only**,
> gated by `contract_version`. A breaking change updates both repos in lockstep.
This document is the formal interface between the **Hermes gateway** (Python,
`gateway/relay/`) and the **connector** (Node/TypeScript,
`NousResearch/gateway-gateway`). The connector implementer's first action is to
read this file.
The gateway runs a generic `RelayAdapter` that dials **out** to the connector,
receives a `CapabilityDescriptor` at handshake, then exchanges normalized
`MessageEvent`s (inbound) and actions (outbound) over a per-turn bidirectional
WebSocket. The gateway never learns which concrete platform is fronting it; the
connector owns all platform-specific socket/identity logic.
---
## 1. Handshake
1. Gateway opens the transport (`connect`).
2. Gateway calls `handshake()`; connector returns a `CapabilityDescriptor`
(section 2) describing the platform this adapter instance fronts.
3. Gateway configures the adapter from the descriptor (char limit, length unit,
draft/edit/thread/markdown capabilities) and registers an inbound handler.
4. Connector then streams inbound events and accepts outbound actions.
`contract_version` (currently `1`) is carried in the descriptor. The gateway
ignores unknown descriptor fields (forward-compat) and fills missing optional
fields from defaults.
---
## 2. CapabilityDescriptor (handshake payload)
JSON object. Source of truth: `gateway/relay/descriptor.py`.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `contract_version` | int | yes | Contract version (additive-only within a version). |
| `platform` | string | yes | Platform name (e.g. `"discord"`, `"telegram"`). |
| `label` | string | yes | Human-readable label. |
| `max_message_length` | int | yes | Char limit; gateway exposes as `MAX_MESSAGE_LENGTH`. 0 → treat as 4096. |
| `supports_draft_streaming` | bool | yes | Native draft-streaming preview support. |
| `supports_edit` | bool | yes | Edit-based streaming possible; if false, consumer degrades to one-message-per-segment. |
| `supports_threads` | bool | yes | `create_handoff_thread` capability. |
| `markdown_dialect` | string | yes | `"plain"`, `"markdown_v2"`, `"discord"`, … (drives `supports_code_blocks`). |
| `len_unit` | string | yes | `"chars"` (builtin len) or `"utf16"` (Telegram UTF-16 code units). |
| `emoji` | string | no | Display emoji (default 🔌). |
| `platform_hint` | string | no | System-prompt platform hint. |
| `pii_safe` | bool | no | Redact PII in session descriptions. |
| `supports_context` | bool | no | Whether the connector can supply surrounding channel/group **context** for an addressed turn on this platform (Model A on-demand history fetch — Discord/Slack/Matrix; Model B passive buffer — Telegram/Signal/WhatsApp). Default false ⇒ no `context` is attached to inbound events. See §3. |
| `supports_inchannel_continuable` | bool | no | Whether the platform can host a **flat continuable cron surface** (native Slack's `cron_continuable_surface: in_channel`): the brief posts top-level in the channel/DM and a plain reply continues the job via the flat `(platform, chat_id, None)` session. Default false ⇒ the gateway's scheduler fails safe to thread mode (D6 gate), so an older connector keeps today's thread behavior. |
| `supports_block_formatting` | bool | no | Whether this platform's sender renders **block-level formatting** from raw markdown when the gateway stamps `metadata.format_hints` on `send`/`edit` frames (Slack: native `markdown` block for tables/lists/code, mrkdwn text kept as fallback). Default false ⇒ the gateway never stamps hints, so an older connector never receives the metadata. |
| `supported_ops` | string[] | no | Op-level capability discovery: the outbound op names the connector's sender for this platform actually implements (e.g. `["send", "edit", "typing", "follow_up", "get_chat_info"]`). Absent/empty ⇒ the connector predates the field and the gateway assumes the legacy op set (`send`/`edit`/`typing`/`follow_up`); a NEW op is used only when explicitly advertised. |
Most fields are a projection of the gateway's existing `PlatformEntry`; the
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
live platform adapter's capability methods.
---
## 3. Inbound: `MessageEvent` envelope
The connector normalizes each platform wire event into a `MessageEvent`
(`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is
delivered over the gateway's OUTBOUND `/relay` WebSocket** (see the transport
note below) — the connector pushes an `inbound` frame down the socket the
gateway already dialed. The gateway keys the session via `build_session_key()`
from the embedded `SessionSource` — so populating the right discriminators is
the single highest-correctness responsibility of the connector.
### Inbound transport (WS back-channel, not HTTP)
The gateway dials **out** to the connector's `/relay` WebSocket for the
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound rides
the **same socket** in the other direction: the connector pushes an `inbound`
frame (and `interrupt_inbound` for §5) down the gateway's outbound WS. There is
**no gateway-side inbound HTTP endpoint** — a gateway need not (and, when hosted,
cannot) expose any inbound port; everything flows over the connection it
initiated.
**Multi-instance routing.** The connector instance that owns a platform's socket
(and thus produces inbound events) is generally **not** the instance the gateway
dialed its outbound WS into. The producing instance therefore publishes the
event on the connector's internal **relay bus** (Redis pub/sub; `RelayBus` in
`src/core/relayBus.ts`) keyed by tenant. Every connector instance subscribes and
routes each message to its **local** sessions for that tenant
(`RelayServer.routeBusMessage`); the single instance that actually holds the
gateway's socket delivers it, and instances with no local session for the tenant
no-op. Cross-instance delivery is thus an in-cluster Redis hop, not a public
HTTP call.
Frames (connector → gateway, over the WS):
- `{"type":"inbound", "event": <MessageEvent>, "bufferId"?}`
- `{"type":"interrupt_inbound", "session_key", "chat_id"}` (§5)
- `{"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?}` (§5.1)
**Channel context on inbound (design relay-channel-context).** When the source
platform's descriptor advertised `supports_context` (§2) and the chat is
multi-party (`chat_type` ∈ group/channel/thread/forum, never `dm`), the
connector MAY attach two optional, additive fields to the inbound `MessageEvent`:
- `context`: an array of read-only surrounding messages (same channel, oldest→
newest) — nearby non-addressed chatter the connector fetched (Model A) or
buffered (Model B). REFERENCE ONLY: it never triggers the agent (the trigger
decision was already made connector-side on the addressed event alone). The
gateway renders it into `MessageEvent.channel_context` (the same read-only
injection path history-backfill uses).
- `context_error`: bool, true when the platform is context-capable but the
fetch/buffer failed and the connector fail-opened to an empty `context`
(observability marker; surfaced connector-side via the delivery span).
Both absent ⇒ byte-identical to today. A connector that never sends them, or a
`dm`, or a no-context platform, yields no `channel_context`.
`PassthroughForward` is the wire form of a forwarded passthrough-plane request
(Class-2/3 webhooks — Discord interactions, Twilio): `{platform, botId, method,
path, headers: [[k,v],…], bodyB64, profile?}`. `profile` is optional — the
connector stamps it when NAS resolves the target profile for a Team-Gateway
interaction; omitting it (single-profile gateways) preserves legacy routing to
the default `agent:main` session namespace, mirroring the `profile` field the
`inbound` frame's `SessionSource` already carries (#60586). The body is
base64-encoded so arbitrary bytes survive the newline-delimited-JSON transport;
the gateway base64-decodes
back to the exact bytes the connector forwarded (the connector already verified
the provider signature and stripped any shared-identity credential at the edge —
§6 — so the gateway re-processes a sanitized, token-free body and acts on it via
the token-less `follow_up` path). See §3.1.
**Trust.** The WS upgrade is authenticated with the gateway's per-gateway secret
(§6.1), so the channel is trusted end to end — inbound frames are not separately
HMAC-signed (the authenticated socket subsumes the per-delivery origin proof the
old HTTP path needed). The relay-bus hop is inside the connector trust domain
(same as the lease/buffer/capability stores).
> Earlier drafts of this contract delivered inbound over a signed **HTTP POST**
> to a `gatewayEndpoint` (`HttpGatewayDelivery` + a gateway-side
> `inbound_receiver`), HMAC-signed with a per-tenant delivery key. That required
> every gateway to expose a reachable inbound URL — impossible for hosted
> gateways, which have no public IP. The WS back-channel above replaces it; the
> per-tenant delivery key is retained at provision for forward-compat but is no
> longer used for inbound. The **passthrough plane** (Class-2/3 webhooks like
> Discord interactions / Twilio) historically still used `gatewayEndpoint` for
> its post-ACK forward; Phase 5 §5.1 moves that forward onto the WS too (the
> `passthrough_forward` frame above), so a hosted gateway needs zero public
> inbound surface and `gatewayEndpoint` is retired once the cutover lands.
### 3.1 Passthrough-plane forward (§5.1)
The passthrough plane answers the provider's latency-critical ACK at the
connector EDGE (e.g. Discord's deferred interaction response within ~3s), then
does a **fire-and-forget** forward of the real request to the gateway. That
forward needs no response back (the provider was already satisfied), so it rides
the same outbound WS as `inbound` via a `passthrough_forward` frame rather than
an HTTP POST. The gateway processes the decoded request through its normal agent
path (a Discord interaction is decoded to a `MessageEvent` and handled like a
message; the reply egresses over the outbound / `follow_up` path). `bufferId` is
present when the forward was buffered (Phase 5 §5.3 buffered-only flip) and the
gateway acks it after durable handoff.
### SessionSource fields (the wire surface)
Source of truth: `SessionSource.to_dict()` in `gateway/session.py`. These are
every key the gateway accepts on the wire. `platform`, `chat_id`, `chat_type`,
`user_id`, `user_name`, `thread_id`, `chat_name`, and `chat_topic` are always
present (may be `null`); the rest are included only when set.
| Field | Type | Always sent | Meaning |
| --- | --- | --- | --- |
| `platform` | string | yes | Platform name (matches the descriptor's `platform`). |
| `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. |
| `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. |
| `chat_name` | string\|null | yes | Human-readable chat name. |
| `user_id` | string\|null | yes | Message author id. Session-key discriminator. |
| `user_name` | string\|null | yes | Author display name. |
| `thread_id` | string\|null | yes | Thread/forum-topic id when in a thread. Session-key discriminator. |
| `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). |
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
| `scope_id` | string | no | Platform-neutral **scope** discriminator: Discord guild / Slack workspace / Matrix server. **REQUIRED for Discord/Slack scope isolation.** Session-key discriminator. (Canonical name as of the D-Q2.5 wire migration.) |
| `guild_id` | string | no | **Legacy alias, no longer read by the connector.** As of D-Q2.5c the connector reads and writes only `scope_id`; the gateway's agent-wide `SessionSource.to_dict()` still emits `guild_id` (mirrored to `scope_id`) for non-relay session persistence, so it may still appear on the wire but the connector ignores it. Do not depend on it. |
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side
> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of
> `to_dict()`. Do not add it to the connector's `SessionSource` until it is
> first added here and to `to_dict()` (additive bump).
### SessionSource discriminators per platform
| Platform | chat_id | chat_type | user_id | thread_id | scope_id |
| --- | --- | --- | --- | --- | --- |
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |
**Get Discord's `guild_id` wrong and two servers collide into one session.**
This is the #1 High-severity risk. The gateway's `build_session_key()` is the
conformance oracle: for a given `SessionSource`, the connector's normalization
must produce the same key the Python adapter would. (The Phase-1 stub tests
assert known-input → known-key.)
### Bot identity vs tenant (single-bot consolidation, Appendix A)
The envelope carries the **originating bot identity** as a field **distinct from
tenant**. Tenant is resolved from the event's own discriminator (Discord
`guild_id`, Telegram `chat_id`, webhook path/subdomain) — **never** from which
token/socket/process delivered it. This keeps one shared bot able to front many
tenants (Phase 6) without overloading an existing field.
### Author-first resolution + the account-link (DM) path (Phase 7)
Phase 7 adds **self-serve, per-user onboarding to a shared bot**, which changes
*which* discriminator resolves the instance for a routed inbound message — and
adds a management path for users to bind their own account.
**Author-first resolution (the multi-tenant-guild rule, D-7.2).** A single
Discord guild may hold **many** tenants — different members each linked to their
own agent. So for delivery the connector resolves the destination instance from
the **authenticated author binding** (`user_instance_binding`, keyed by
`(tenant, platform, platform_user_id)` via `resolveByUser`), **NOT** by a
guild→instance route. Concretely:
- A routed message authored by a **linked** user reaches **only that user's**
instance — even when a second linked user in the **same guild** is served by a
different instance (each reaches only their own).
- A message authored by an **unlinked** user resolves to **no** instance and is
dropped (**fail-closed** — never broadcast to the guild's other tenants).
- The author id used is the **authentic `user_id` off the observed event**, the
same `SessionSource.user_id` documented above — never a value asserted by a
gateway or carried in a management frame.
This is the per-`user_id` owner-only routing the connector enforces in
`WsGatewayDelivery` (the gateway-side multi-tenant-guild E2E driver
`gateway_multitenant_guild_driver.py` is the cross-repo oracle).
**The account-link (DM) path.** A user binds their account to an instance with a
one-time code, redeemed by DMing the shared bot:
1. The owner triggers a link from the Portal (or a self-hosted CLI). The
connector mints a short-lived **link code** for the **authenticated**
instance (`POST /manage/link`; instanceId comes from the caller's principal —
a NAS-signed `aud=agent:{instanceId}` token or the instance's own per-gateway
secret — **never** the request body).
2. The user sends `/link <code>` as a **direct message** to the shared bot from
the account they want to bind.
3. The connector's inbound observer **consumes** that DM (it is not routed to any
agent) and writes the `user_instance_binding` using the **authentic
`user_id`** off the observed DM event. From then on, author-first resolution
routes that user's messages to the bound instance.
**Opt-out is connector-authoritative.** Deprovisioning an instance
(`POST /manage/deprovision`) drops its author bindings (so its users stop
resolving to it) **and** revokes its per-gateway secret (so its socket can no
longer authenticate — the next WS upgrade is closed **4401**). A gateway that
sees a **4401 close after a previously-successful handshake** treats it as a
terminal revocation: it stops reconnecting and reports the relay platform as
**disabled** (not a retryable error). A 4401 *before* any successful handshake
stays retryable (a cold-start / not-yet-provisioned race, not a revocation).
### 3.2 Going-idle / buffered-flip primitive (§5.3)
A scale-to-zero PRIMITIVE (not the behaviour — nothing here decides to sleep or
suspends a machine; a later workstream consumes these frames). It lets a gateway
enter a drain/idle transition without losing inbound that arrives while it is
gone, by making the connector buffer for that instance and replay on reconnect.
Three frames (all keyed by the connection's **authenticated** per-instance id —
read off the stored secret record at the WS upgrade, never asserted in a frame):
- `{"type":"going_idle"}` (gateway → connector) — emitted as part of the
gateway's EXISTING drain transition (the adapter sends it before tearing down
the socket). Asks the connector to flip this instance to **buffered-only**.
- `{"type":"going_idle_ack"}` (connector → gateway) — the connector has flipped:
live delivery has stopped and subsequent inbound for this instance buffers
durably. The gateway **stays serving until this ack** (so an event landing in
the flip window is delivered live, not lost — the same SUBSCRIBE-before-serve
ordering discipline as the bus). Only after the ack is it safe to close.
- `{"type":"inbound_ack", "bufferId"}` (gateway → connector) — durable receipt of
a buffered `inbound` delivery (which carries its `bufferId`) replayed on
reconnect. The connector acks the buffer entry only after this, giving
drain-without-dup on the **delivery leg**: an instance that dies mid-drain
redelivers exactly the unacked tail; an acked entry never redelivers.
**Buffer + drain.** While flipped, the connector appends inbound to a durable
per-instance delivery-leg buffer (`delivery:<instanceId>`) instead of pushing it
live. On the gateway's **reconnect** (a NET-NEW reconnect loop re-dials +
re-handshakes after an unexpected close), the new handshake triggers the
connector to drain that backlog over the new socket **in order, ack-gated**,
then clear the flip so live delivery resumes. This reuses the same
`drainWithoutDup` machinery as the Discord→connector ingest leg, applied to the
connector→gateway delivery leg. Connector-authoritative throughout: a gateway can
only flip/drain ITS OWN instance.
> NOT in scope (deferred behaviour): the autonomous idle timer that DECIDES to
> drain, the actual machine suspend, and the NAS suspended-health model. The
> primitive is "when the gateway drains, relay flips to buffered + replays on
> reconnect, with no loss/dup"; WHAT triggers the drain is out of scope.
### 3.3 Wake poke (§5.2)
The other half of the sleep/wake loop: how a SUSPENDED gateway finds out it has
buffered work waiting. A PRIMITIVE — nothing here suspends a machine; it wires
the wake SIGNAL so a future scale-to-zero behaviour layer can rely on "buffered
⇒ wake poked."
- **Registration.** The gateway registers a **wake URL** at enroll/provision —
any reachable URL the connector can GET to wake it (a Fly autostart hostname,
a dashboard host). Self-hosted: `hermes gateway enroll --wake-url <url>` (or
`GATEWAY_RELAY_WAKE_URL` / `gateway.relay_wake_url`). Managed/NAS: stamped into
the container env beside `GATEWAY_RELAY_URL`. Forwarded in the
`/relay/provision` body as `wakeUrl` and stored per-instance on the connector's
secret record (gateway-asserted but safely scoped — same posture as
`instanceId`; the org/tenant stays token-verified, so a gateway can only
register a wake target for ITS OWN instance). DISTINCT from the retired
`gatewayEndpoint`: a **poke target**, not a delivery target.
- **The poke.** When a buffered-only (going-idle) destination receives its FIRST
buffered event, the connector issues a **payload-free, unsigned GET** to that
instance's registered `wakeUrl`, **directly** (NOT NAS-mediated — relay stays
NAS-independent). It carries no tenant data and no inbound: it only says "you
have buffered work, reconnect." Tenant authority is re-established the normal
way when the gateway re-dials (the authenticated WS upgrade), so a leaked/
guessed wake URL can at worst cause a spurious reconnect of ITS OWN instance.
Rate-limited per instance (one poke per cooldown window, not per event), and
best-effort — a failed poke is swallowed; the gateway still drains whenever it
next reconnects on its own. No new frame: the wake is an out-of-band HTTP GET,
not a relay-WS message (the socket is down — that's the whole point).
> NOT in scope (deferred behaviour): the actual machine suspend (Fly
> `autostop:"suspend"`) and the autonomous idle timer that decides to sleep. The
> primitive is "buffered event for a sleeping instance ⇒ its wakeUrl gets poked";
> WHAT makes the instance sleep (and wake-to-serve) is the behaviour layer.
### 3.4 Obligations on a future scale-to-zero behaviour layer
§3.2 and §3.3 ship the **primitives**; this section is the **contract a separate
scale-to-zero behaviour workstream must honour to consume them safely.** It owns
the *decision* to suspend, the actual machine suspend, and the platform/health
model — none of which live here — but it MUST hold these guarantees, which the
primitives assume:
1. **Register a `wakeUrl` before the instance can ever be suspended.** A
suspended instance with no registered `wakeUrl` is a black hole — buffered
inbound never triggers a poke, so it sleeps through its own traffic until
something else reconnects it. The behaviour layer MUST ensure a reachable
wake target is registered (self-hosted: `--wake-url`; managed: stamped) as a
precondition of allowing suspend. A wake URL that is unreachable while the
machine is suspended (e.g. points at the suspended machine itself with no
platform autostart in front) is equivalent to none.
2. **Drain through `going_idle` → await `going_idle_ack` BEFORE tearing down the
socket or suspending.** Never suspend with an un-acked flip in flight. The
ack is the connector's confirmation that delivery for this instance is now
buffered-only; a machine that suspends after sending `going_idle` but before
the ack can drop the inbound that races the flip. The gateway already gates
socket teardown on the ack (Q-5.3c); the suspend step MUST sit *after* a
clean drain completes, not race it.
3. **Keep the NET-NEW reconnect loop live as a precondition of suspend.** The
wake→drain contract is "poke ⇒ the gateway re-dials ⇒ the connector drains on
the reconnect handshake." If the reconnect loop is disabled, a poke lands on a
machine that never re-dials and the buffer strands. The behaviour layer must
not suspend an instance whose relay transport won't reconnect on wake.
4. **Treat suspended ≠ down in the health model (Q-5.3b).** A suspended instance
is healthy-asleep, not failed. The health/monitoring layer MUST distinguish
the two (e.g. via the platform machine-state) so a suspended instance is not
restarted, alerted on, or reaped as unhealthy — that would defeat the suspend
and can race the wake/drain.
5. **The wake poke is best-effort and rate-limited — do not assume exactly-once
or immediate wake.** At most one poke per cooldown window per instance, and a
failed poke is swallowed. The behaviour layer must not rely on the poke as a
guaranteed/prompt signal; correctness still rests on "the gateway drains
whenever it next reconnects." A belt-and-suspenders wake (e.g. a scheduled
job that also reconnects) is the behaviour layer's call, not the primitive's.
6. **Suspend only when genuinely idle — and idle is connector-observable, not
gateway-guessed.** WHAT counts as idle (no in-flight turn + no inbound for N
min) is the behaviour layer's policy, but it must compose with the existing
drain machinery (`gateway_state` running→draining) rather than introduce a
parallel relay-only idle path — the same integration constraint §3.2 places
on `going_idle`.
These are guarantees the behaviour layer OWES the primitives; the primitives owe
the behaviour layer only what §3.2/§3.3 already specify (a flip-on-going_idle,
a durable per-instance buffer + ack-gated reconnect drain, and a poke on the
first buffered event for a flipped instance).
---
## 4. Outbound: action set
The gateway calls the transport with action dicts. Source of truth:
`gateway/relay/transport.py` + `gateway/relay/adapter.py`.
| `op` | Fields | Result |
| --- | --- | --- |
| `send` | `chat_id`, `content`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
| `edit` | `chat_id`, `message_id`, `content`, `metadata?` | `{success: bool, error?}` |
| `typing` | `chat_id`, `content?`, `metadata?` | `{success: bool}` |
| `follow_up` | `session_key`, `kind`, `content`, `metadata?` | `{success: bool, message_id?, error?}` |
| `send_media` | `chat_id`, `media_kind`, `source_url`, `content?` (caption), `filename?`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
| `prompt` | `chat_id`, `prompt_kind`, `prompt_id`, `content` (the question), `options[]{id,label,style?}`, `timeout_s?`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
| `react` | `chat_id`, `message_id`, `emoji`, `remove?`, `metadata?` | `{success: bool, error?}` |
| `thread_create` | `chat_id` (parent), `thread_name`, `message_id?` (anchor), `metadata?` | `{success: bool, thread_id?, error?}` |
| `thread_rename` | `chat_id` (parent), `message_id` (the THREAD id), `thread_name`, `only_if_current_name?`, `metadata?` | `{success: bool, error?}` |
`get_chat_info(chat_id)` is a separate proxied call returning at least
`{name, type}`.
**`send_media` (Phase 2 media egress).** Media crosses the wire BY REFERENCE:
`source_url` is either (a) a **connector re-host** the gateway previously
uploaded via `POST {connector}/relay/media` (raw bytes body, `Content-Type` +
optional `X-Media-Filename` headers, per-gateway HMAC bearer — the same token
scheme as the WS upgrade; response `{id, size}` → reference
`{connector}/relay/media/{id}`), or (b) a **public http(s) URL** (e.g. a
fal.media generation) the connector downloads directly. `media_kind` is one of
`image` / `voice` / `audio` / `video` / `document` and selects the
platform-native upload lane (Telegram `sendPhoto`/`sendVoice`/…, Discord
multipart attachment, Slack external upload, WhatsApp media upload + media
message). The caption rides `content` and renders through the platform's
normal markdown lane; platforms without native captions get a follow-up text
send (connector-side). Both routes and the op are gated on `supported_ops`
advertising `send_media` — a legacy connector never sees the op (the gateway's
media sends degrade to their pre-media text fallbacks). Size cap 25 MB
(connector `mediaStore.ts` MEDIA_MAX_BYTES; uploads over it are rejected 413).
**Inbound media (Phase 2 media ingress).** An inbound event's `media_urls`
carry fetchable references: platform-public URLs pass through (Discord CDN);
auth-gated/expiring platform URLs (Telegram file API, Slack `url_private`,
WhatsApp Graph media) are downloaded connector-side with the PLATFORM
credential and re-hosted as `{connector}/relay/media/{id}` — the platform
credential never crosses the wire. Re-host references are readable by any
authenticated gateway (capability-URL semantics: the id is 128-bit random and
was already delivered to every admitted recipient); the gateway downloads each
reference with its per-gateway bearer and presents LOCAL file paths to the
agent, mirroring native adapters. Re-hosts expire (TTL ~1h) — download on
receipt, not lazily. A parallel `media` array (same order) adds `kind`, `mime`,
`size`, `filename`, `caption` metadata; `message_type` reflects the first
attachment's kind (`image`/`audio`/`document`).
**`prompt` (Phase 3 interactive).** One platform-abstract op renders the
gateway's highest-stakes interactions (exec approvals, slash confirms,
clarify pickers) with NATIVE controls: Discord button components, Telegram
inline keyboards, Slack Block Kit actions, WhatsApp button messages (≤3
options) / list messages (410; >10 degrades to the numbered-text fallback).
`prompt_kind` (`approval`/`clarify`/`choice`) is a styling hint only.
`prompt_id` is gateway-minted and opaque to the connector; each
option's callback payload carries the token `hp1:<prompt_id>:<option_id>`
(≤64 bytes — Telegram's `callback_data` cap binds every lane; option ids are
`[A-Za-z0-9_.-]`, ≤32 chars). The gateway mints the prompt id as
`<per-process nonce>.<8 hex>` within that same alphabet and budget: the
connector fans a passthrough forward (a Discord press) out to EVERY live
gateway session of the tenant, unlike a message, which it narrows to the
admitted instance set, so the nonce is how a gateway tells its OWN prompt
from a sibling's. `style` maps per-platform
(primary/success/danger/secondary). `timeout_s` is advisory on the wire —
expiry is enforced GATEWAY-side (the pending-prompt registry drops expired
entries; the owning gateway then replies with a short "no longer waiting"
notice).
**`prompt_response` (Phase 3 inbound).** The user's press crosses back as a
normal inbound MessageEvent carrying
`prompt_response: {prompt_id, option_id, label?, prompt_message_id?}` — never
a bare platform `custom_id`. The event's `text` mirrors `/{option_id}` with
`message_type: "command"` so a gateway predating the field routes the press
as a typed reply instead of dropping it. A gateway that DOES understand the
field always consumes the press instead: a prompt id it did not mint belongs
to a sibling gateway that the same fan-out also reached, and letting the
`/{option_id}` text reach the chat lane made every sibling answer
"Unknown command" under the owner's single ack. The SOURCE is the authentic
CLICKING user (connector-observed: Telegram `callback_query.from`, Slack
`block_actions.user`, WhatsApp `messages[].from`, Discord interaction
member/user), so gateway-side authorization gates apply to a button press
exactly as to a typed `/approve`. Ingest lanes: Telegram `callback_query`
(polled, `allowed_updates` widened; best-effort `answerCallbackQuery`
spinner-stop), Slack `POST /slack/interactions` (raw-bytes HMAC + replay
window, same posture as `/slack/commands`), WhatsApp interactive
`button_reply`/`list_reply` (webhook normalize arm), Discord type-3
component interactions (passthrough §5.1 sanitized forward; the type-3 edge
ack is `DEFERRED_UPDATE` so no visible "thinking…" reply). Foreign
callback payloads (another integration's buttons) never become prompt
events: Telegram/Slack/WhatsApp drop them at the connector; Discord type-3
forwards keep the legacy custom_id-as-text shape.
**`react` (Phase 3 ack lifecycle).** Adds/removes the bot's own `emoji`
reaction on `message_id` — restoring the native adapters' 👀→✅/❌
processing-lifecycle acks over the relay. Unicode emoji on the wire; the
Slack sender maps to Slack's name vocabulary (`eyes`, `white_check_mark`, …)
and treats `already_reacted`/`no_reaction` as success (idempotent). Telegram
uses `setMessageReaction` (empty set = remove; Telegram's curated-emoji
restriction can reject glyphs — the failure is structured and the gateway
treats reactions as cosmetic). WhatsApp sends a reaction message (empty
emoji = remove). Reactions are best-effort by contract: a `react` failure
must never fail a turn.
**`thread_create` / `thread_rename` (Phase 4 thread lifecycle).** One
platform-abstract pair covers handoff threads, Telegram DM/forum topics, and
LLM-title semantic renames. `thread_create`: Discord posts a channel thread
(type 11) or a message-anchored thread when `message_id` is set; Telegram
`createForumTopic` (topic id returned); Slack posts a NAMED seed root
message and returns its `ts` (threads there are message-anchored — an
explicit `message_id` anchor is echoed back verbatim). The created id rides
`SendResult.thread_id`. `thread_rename`: Discord PATCHes the thread channel;
Telegram `editForumTopic`. The **`only_if_current_name` no-clobber guard**
is the native adapters' human-rename-wins semantics, enforced
CONNECTOR-side: Discord reads the current name first and no-ops (structured
`success:false`) on mismatch; Telegram has no topic-name read, so a GUARDED
rename is unsatisfiable and fails safe (unguarded renames proceed). Slack
does not advertise `thread_rename` (a root message's text is content, not a
name). WhatsApp advertises neither (no threads).
**Auto-thread markers + gateway-declared command manifest (Phase 4
inbound/handshake).** When the connector's auto-thread egress policy creates
a Discord thread, later inbound events from that thread carry
`source.auto_thread_created: true` + `source.auto_thread_initial_name` — the
connector-observed evidence that lights the gateway's semantic-rename lane
(the LLM session title renames the thread via a GUARDED `thread_rename`;
per-instance memory, so in an N>1 fleet a miss simply never lights the
lane). The gateway may also declare its slash-command set on the Discord
`hello` frame (`command_manifest: [{name, description, options?}]`); the
connector reconciles Discord's GLOBAL application-command registration
against it (GET → diff → bulk PUT overwrite; idempotent, debounced,
best-effort — a registration failure never affects the handshake). Commands
still dispatch through the passthrough plane as before; the manifest only
keeps Discord's registry in sync with what the gateway's dispatcher handles.
**Inbound `reply_to` enrichment (Phase 4).** A platform reply may carry
`reply_to: {text?, author?, is_own?}` alongside `reply_to_message_id` — what
the user QUOTED, populated only from data the connector already had in hand
(Discord's inline `referenced_message`, Telegram's inline
`reply_to_message`, WhatsApp `context.from` + a bounded per-instance
inbound-text cache for the text leg). Absent fields mean the platform didn't
carry the data — never triggers an extra platform API call. `is_own` = the
quoted message was authored by the fronted bot (same evidence as the
`is_reply_to_bot` relevance marker). The gateway maps these onto the same
MessageEvent reply-context fields native adapters populate.
**`typing` `content?` (Slack status clear).** A `typing` frame normally omits
`content` — the connector renders its platform's active indicator ("is
typing…" Assistant status on Slack, one-shot typing elsewhere). An **empty
string** `content` is an explicit *clear* request: on Slack the connector sets
the Assistant thread status to `""`, dismissing it. The gateway emits the
clear only for Slack (persistent status); one-shot platforms never receive it.
Additive within `contract_version` 1, but note the deploy order: a connector
predating gateway-gateway #154 ignores `content` and would *set* "is typing…"
on a clear frame — deploy the connector first.
**`follow_up` (A2 capability action).** Some inbound payloads carry a credential
that acts on the **shared** bot identity (e.g. a Discord interaction follow-up
token). Per §6 the connector strips that at the edge and binds it in its
capability vault keyed by the session; it **never reaches the gateway**. To use
it, the gateway issues `follow_up` naming the **session it is already in**
(`session_key`) plus the capability `kind` (e.g. `discord.interaction_token`) —
**never a token**. The connector resolves the real value from its vault,
enforces the tenant match (tenant B can never wield tenant A's capability), and
egresses. `success: false` when the capability is absent/expired or the tenant
doesn't match — the gateway has nothing to retry with, by design (a leaked
gateway holds zero capability material). Source of truth:
`gateway/relay/transport.py` (`send_follow_up`) + `gateway/relay/adapter.py`.
---
## 5. Interrupt (`/stop`) routing
- **Gateway → connector:** `send_interrupt(session_key, reason?)` egresses a
mid-turn `/stop` over the outbound WS. The connector MUST forward it to the
gateway instance running that `session_key` (the routing invariant).
- **Connector → gateway:** an inbound interrupt for a `session_key` is delivered
as an `interrupt_inbound` frame down the gateway's outbound WS (§3 transport
note) — routed cross-instance via the relay bus to whichever instance holds
the socket — and bridged by the adapter's `on_interrupt(session_key, chat_id)`
into the existing per-session interrupt mechanism, cancelling exactly that turn
(siblings untouched).
Both directions ride the gateway's outbound WS: the gateway→connector `/stop`
egresses over it, and the connector→gateway interrupt rides the same `inbound`
back-channel as a normalized event.
---
## 6. Trust boundary & signed-body handling (A2)
**The connector is the sole crypto/identity boundary. The gateway re-validates
nothing.**
Webhook signatures (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt) are
computed over exact raw bytes, and some payloads are *encrypted* with a shared
secret. The connector fronts a **shared** bot for many tenants and holds every
tenant's platform secrets, so it:
- **verifies / decrypts at the edge** (the only place the secrets live),
- **normalizes** the payload into a tenant-scoped `MessageEvent` (§3),
- **strips any shared-identity capability** out of the payload and binds it in
its capability vault, keyed by the session (see §4 `follow_up`),
- **forwards only the sanitized `MessageEvent`** — never the raw signed body.
The gateway therefore performs **no** platform signature/crypto verification on
the relay path; it trusts the normalized event. This is an enforced invariant on
the gateway side (`tests/gateway/relay/test_relay_sheds_crypto.py`: the relay
package imports/calls no platform-crypto).
**Why not "forward the signed body byte-for-byte so the gateway re-validates"?**
That earlier model is incoherent under an untrusted, disposable tenant gateway:
- Re-validating Twilio HMAC / WeCom crypto would require handing the gateway the
**shared signing secret** — which is itself the leak, and on a shared bot it's
a *cross-tenant* leak.
- WeCom payloads are encrypted with the shared secret; the connector must decrypt
at the edge just to route, so forwarding ciphertext would again require giving
the gateway the secret.
- A Discord interaction token lives **inside** the signed JSON body — you cannot
both preserve the bytes and strip the credential; they are the same bytes.
So byte-preservation is abandoned deliberately: the connector re-serializes the
sanitized event and the gateway trusts it. This also unifies the passthrough and
relay planes — both are "verify at the edge → emit a normalized event," differing
only in transport. See `docs/capability-trust-boundary.md` (connector repo:
`gateway-gateway`) for the full A2 rationale and the connector-side vault.
### 6.1 Channel authentication (the connector⇄gateway link itself)
A2 makes the connector the sole holder of platform secrets while the gateway may
be **customer-managed and internet-exposed**, so the connector⇄gateway channel
is itself authenticated. The gateway holds an enrollment- or provision-issued
**per-gateway secret** (`hermes gateway enroll` → connector `/relay/enroll`, or
managed self-provision → `/relay/provision`) that authenticates its outbound WS
upgrade. It is an HMAC-SHA256 scheme with a multi-secret rotation verify list
(gateway side: `gateway/relay/auth.py`; connector side:
`src/core/relayAuthToken.ts`).
| Leg | Credential | Mechanism |
|-----|-----------|-----------|
| Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. |
| Connector → gateway inbound (`inbound` / `interrupt_inbound` frames) | — (rides the authenticated WS) | Inbound is pushed down the gateway's already-authenticated outbound socket (§3), so no per-message signature is needed. A **per-tenant delivery key** is still issued at enroll/provision and retained for forward-compat, but is no longer used to sign inbound. |
This is the **channel** authenticator — distinct from platform crypto, which the
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
the per-gateway secret authenticates only the connector link. Full threat model +
enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
(connector repo).
---
## 7. Per-instance delivery & the management plane (Phase 6)
Phases 15 treat the connector as a single-tenant front: inbound events for a
tenant fan out to that tenant's gateway socket(s). **Phase 6 makes delivery
per-INSTANCE** — a shared bot can front many users/agents in one tenant (one
Discord guild, one Telegram bot) without cross-delivery — and adds a small
**management plane** the agent (or a managed Portal) uses to declare who-sees-what
and what's-relevant. All of this lives **connector-side**; the gateway's only new
responsibility is to **declare its relevance policy** at boot (§7.3).
### 7.1 The delivery gate (connector-side, informational)
For each inbound event the connector decides which instances receive it by
composing three AND-ed filters. The gateway does not implement these — they run
in the connector — but they define the delivery semantics the gateway relies on:
| Layer | Question | Source of truth |
| --- | --- | --- |
| **owner / scope ∧ principal** | May this instance *see* this author here? | per-user `user_id → instance` bindings (the owner floor) + per-instance `(guild, channel)` scope grants + an `owner-only` / `allow-list` / `any` principal policy. |
| **visibility floor** | Can the instance's bound owner actually `VIEW_CHANNEL` this in Discord? | live Discord ACL (effective permissions), fail-closed. Narrows an over-broad scope grant downward. |
| **relevance** | *Given* it may see it, should the agent engage? | the relevance policy declared in §7.3 (address-gating / free-response / allow-bots). |
The composition only ever **narrows** delivery (`deliver ⇔ authorized ∧ visible
∧ relevant`); the **owner floor bypasses the relevance layer** (an author's own
message always reaches their own instance — you don't @mention your own agent).
A message authored by an unbound user reaches no instance (fail-closed). The
full design + invariants live in the connector repo
(`NousResearch/gateway-gateway`); this section is the gateway-facing summary.
### 7.2 Management routes (connector-side, authenticated)
The connector mounts authenticated management routes. They share the **same
dual-auth** as the WS upgrade: either a managed NAS-signed `aud=agent:{instanceId}`
RS256 JWT, **or** the gateway's own per-gateway secret bearer (§6.1
`make_upgrade_token`). In both cases the connector resolves the authoritative
`{tenant, instanceId}` from its **stored** record — **never** from the request
body (a body-asserted `instanceId` is ignored).
| Route | Purpose |
| --- | --- |
| `POST /manage/link` | Issue a short-lived code to bind a platform account to the authenticated instance (the `/link <code>` flow; the connector reads the authentic `user_id` off the inbound event). |
| `POST /manage/scope`, `/manage/scope/release` | Claim / release a `(guild, channel)` scope for the authenticated instance. A channel is owned by at most one instance (non-overlap is a PK constraint). |
| `POST /manage/principal` | Set the instance's principal policy (`owner-only` \| `allow-list` \| `any`). |
| `POST /manage/dm-default` | Set the user's DM-default instance (DM tie-break when a user linked more than one). |
| `POST /relay/policy` | Declare the instance's **relevance policy** (§7.3). |
These are connector-owned (the management plane is not part of the gateway's
agent path); the gateway only calls `POST /relay/policy` (§7.3). The others are
driven by the managed Portal / `hermes` CLI.
### 7.3 Relevance-policy declaration (the gateway's responsibility)
The relevance layer (§7.1) is the per-tenant parity for the gateway's own
behaviour knobs (`require_mention`, `free_response_channels`,
`{PLATFORM}_ALLOW_BOTS`). So the **same** behaviour governs relay delivery, the
gateway projects those knobs into a **platform-agnostic** policy and POSTs it to
`POST /relay/policy` at boot (after its per-gateway secret is resolved).
Body (`gateway/relay/__init__.py` `relay_relevance_policy()``send_relay_policy()`):
| Field | Type | Projected from | Meaning |
| --- | --- | --- | --- |
| `platform` | string | the fronted platform (`relay_platform_identity`) | which platform this policy applies to. |
| `requireAddress` | bool | `require_mention` | a non-owner message must @mention / reply-to the bot to be relevant. |
| `freeResponseScopes` | string[] | `free_response_channels` | scope (channel) ids where `requireAddress` is waived. Same scope vocabulary as §7.1's scope grants. |
| `allowOtherBots` | bool | `{PLATFORM}_ALLOW_BOTS ∈ {mentions, all}` | admit bot-authored messages (default off). |
Auth is the per-gateway upgrade token (§6.1), so the connector attaches the
policy to the authenticated instance. The gateway is the **source of truth** and
re-declares **every boot** (a full replace, mirroring the `routeKeys` upsert at
provision — self-healing). When the projected policy is all-default the gateway
sends nothing (the connector's absent-row default already matches). The POST is
**fail-soft**: a failure logs and boot proceeds — relevance is an optimization
layered on the authorization gate (§7.1), never a boot dependency. There is **no
new gateway inbound surface** and **no new credential** — it reuses the
per-gateway secret and the same host as `/relay/provision`.
> A relevance drop happens **before** the connector wakes a scaled-to-zero agent
> (Phase 5), so excluded chatter never spins an agent up — relevance is the
> primary scale-to-zero lever as well as a correctness filter.
---
## 8. Gateway-side platform behavior controls (enterprise)
Enterprise deployments configure fronted-platform behavior on the GATEWAY
side, under `platforms.relay.extra.<platform>` — a supported subset of that
platform's native options. The native platform block (e.g. `platforms.slack`)
is not read on the relay lane; the connector receives the *outcome* of these
controls as frame metadata (§4) and executes mechanically — it holds no
platform behavior policy of its own.
```yaml
platforms:
relay:
extra:
slack:
reply_in_thread: true # default
```
Resolution: nested `extra.<platform>` object wins → legacy flat key on
`extra` honored as fallback → default. Source of truth:
`RelayAdapter._effective_reply_in_thread` (`gateway/relay/adapter.py`).
Values coerce exactly as the native Slack adapter's do — `1/true/yes/on`
(case-insensitive, whitespace-trimmed) are ON, anything else is OFF — so a
YAML-quoted `"false"` turns a knob off rather than being read as a truthy
string.
Current controls (Slack):
| Key | Default | Effect |
| --- | --- | --- |
| `reply_in_thread` | `true` | `true`: thread-per-message — each top-level DM message anchors its own thread (status, progress, prompts, final reply all carry that `metadata.thread_id`). `false`: flat rolling DM — send-lane frames carry NO thread anchor (stripped, not omitted), one shared session per DM. |
| `dm_top_level_threads_as_sessions` | `true` | Native-parity escape hatch (mirrors `platforms.slack.extra.dm_top_level_threads_as_sessions`). `true`: in thread-per-message mode each top-level DM message keys its own session, so concurrent messages run in parallel. `false`: threaded reply placement is kept but the session stamp is skipped — one rolling DM session (legacy steer/queue posture). No effect in flat mode, which always keeps the single rolling session. |
Typing/status frames always carry the triggering-ts anchor when one is known
(liveliness is unconditional, both modes): Slack's status line is
thread-scoped, and in flat mode the send-side anchor strip guarantees the
status anchor can never leak into reply placement. Semantics of the native
key: see `website/docs/user-guide/messaging/slack.md`.
Thread-anchor resolution applies to EVERY send lane — text (`send`) and media
(`send_media`) alike — through one choke point
(`RelayAdapter._apply_slack_thread_anchor`). Media frames egress via the same
connector-side Slack sender, which threads on `metadata.thread_id` only, so an
attachment resolves its anchor identically to a text reply: promoted into
metadata in thread-per-message mode, stripped in flat mode.
Changes take effect on gateway restart; no connector involvement.
---
## 9. Versioning policy
- `contract_version` is an int; bump **only** for additive changes during the
experimental phase (new optional fields, new `op`s).
- A breaking change (renamed/removed field, changed semantics) requires a
coordinated update of both repos and a version bump.
- The connector's first PR references the commit SHA of this file it implements
against.
@@ -0,0 +1,134 @@
# Research spike: plugin-architecture lessons from Pi and OpenCode
**Issue:** #64180 · **Informs:** #64164 (event bus), #64161 (streaming hooks), #64162 (pluggable approval), #64165 (manifest v2), #64229 (lifecycle/ledger), #64230 (Plugin Doctor)
**Method.** Both systems were read at source level (shallow clones pinned to a commit), not from docs sites alone: Pi (`badlogic/pi-mono`, now `earendil-works/pi`) at `eb79351` (v0.80.7, 2026-07-14) and OpenCode (`sst/opencode`, now `anomalyco/opencode`) at `c69abee` (v1.18.2, 2026-07-16). Claims below carry file:line references into those commits. Where something could not be found (ADRs, policies, timeouts), that absence was verified by search and is reported as a finding. Hermes ground rules from #64182 (additive-only, prompt-cache sacred, observer-first, fail-closed security) are treated as overriding constraints throughout — this report grades imported patterns against them, per the adapt-don't-copy rubric.
**Headline.** The two systems are near-perfect opposites on the four design axes Hermes currently has open, which makes them a natural controlled experiment:
| Axis | Pi | OpenCode | Hermes proposal on the table |
|---|---|---|---|
| Per-delta streaming hook | Yes — awaited inline, no timeout | Structurally absent (text-end only) | Observer per-delta + never-block contract (#64161) |
| Veto semantics | Typed per-event result vocabulary (`{block}`, `{cancel}`, `"handled"`) | Veto-by-throw (bug and policy denial indistinguishable) | TBD in #64162 |
| Guard-hook failure | Fail closed (`tool_call` only) | No runtime containment at all | Ground rule 4: fail closed on security-adjacent |
| Plugin event bus | Yes — 33 lines, **un-namespaced** channels | None (plugins observe core bus, cannot emit) | Namespaced `ctx.emit`/`ctx.subscribe` (#64164) |
Neither system has hook timeouts. Both have shipped hang-class or drift-class failures because of it. That is the single strongest cross-cutting lesson for Hermes.
---
## 1. Pi (badlogic/pi-mono → earendil-works/pi)
Extensions are in-process TypeScript modules (loaded via jiti) receiving an `ExtensionAPI`; no separate process, no IPC, no manifest permissions. Pi explicitly rejected MCP as its extension mechanism (Zechner, ["What if you don't need MCP at all?"](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)). Notably, Pi's founding "minimal, no hooks" stance (Nov 2025) reversed into a 33-event extension system within ~7 months — extensibility demand won; the no-MCP stance held.
### 1.1 Hook/event taxonomy
33 event types (`src/core/extensions/types.ts:507-902`). The load-bearing design choice: **every mutating event gets its own typed emitter with its own result vocabulary**, rather than one generic middleware pipe —
- `tool_call``{ block: true, reason }`; argument mutation in place, explicitly "no re-validation after your mutation" (`docs/extensions.md:742-765`)
- `session_before_*``{ cancel: true }`, first canceller wins, later handlers skipped
- `input``"handled"` (short-circuit) vs `"transform"` (chain) (`runner.ts:1148-1188`)
- `tool_result` → partial-patch accumulation across handlers (`runner.ts:835-883`)
- `before_agent_start` → system-prompt chaining with a live `ctx.getSystemPrompt()` reflecting earlier handlers (`runner.ts:1034-1098`)
- Observer events have **no return channel at all** — the observer/mutator split is enforced by which emitter the event flows through, not by convention.
Dispatch is fully sequential async (`runner.ts:759-791`): every handler awaited one at a time; the only ordering rule is extension load order (project → global → CLI, `loader.ts:660-708`) then registration order. No priorities, no phases — and no community demand for them in ~8 months. Name collisions are handled per registry with explicit deterministic policies instead of dependency resolution: first-wins tools, suffixed duplicate commands (`name:2`), last-wins shortcuts with a warning plus an 18-key reserved denylist (`runner.ts:421-604`).
Two ordering guarantees worth copying: extensions see events **before** the UI and **before** session persistence (`agent-session.ts:596-601`), and parallel sibling tool calls are "preflighted sequentially, then executed concurrently" (`docs/extensions.md:750`).
### 1.2 Plugin-to-plugin interaction
A 33-line shared event bus (`src/core/event-bus.ts`): `pi.events.emit/on` on **arbitrary string channels** — no namespacing, no declarations, no collision protection; per-handler try/catch and an unsubscribe closure. Everything richer (capability registries, dependencies) is deliberately absent; a community RFC to build richer coordination as an extension (pi#2715) was left to userland.
### 1.3 Compatibility strategy
No API versioning, no handshake, no deprecation annotations. In its place, three working practices: (a) loader-level alias shims that kept old imports resolving across the `@mariozechner``@earendil-works` package rename and a pi-ai API split, with pre-announced removal (`loader.ts:47-71`; `CHANGELOG.md:243`); (b) loud "Breaking Changes" changelog sections with **automatic migrations** (directory renames on startup, session format v2→v3 auto-migrated) for the big hooks+customTools→extensions unification (v0.35-0.37); (c) per-tool `prepareArguments()` shims for old argument shapes.
Real breakage on record: pi#2860 — an internal session-management refactor made `pi.sendUserMessage()` after `ctx.newSession()` silently drop messages. The remediation pattern is distinctive: **stale-context poisoning** — after session replacement, every captured context getter throws a paragraph-long teaching error pointing at the safe `withSession` pattern (`runner.ts:514-527`; `docs/extensions.md:1223-1265` documents the footgun with unsafe-pattern code). Compat by making misuse loud, not by never changing.
### 1.4 Failure isolation
Contain errors, don't contain time: every handler call is individually try/caught and surfaced (red stack trace in chat UI), load failures skip only the broken extension, and a `pi -ne` no-extensions escape hatch exists. The **one deliberate exception**: `tool_call` has no internal catch — a guard-hook crash blocks the tool ("Extension failed, blocking execution"), converted into an error tool result the LLM sees; the loop survives, the guard fails closed (`runner.ts:885-906`; `agent-session.ts:454-467`; `agent-loop.ts:657-665`).
**There are zero timeouts.** All handlers — including per-token `message_update` — are awaited inline in the agent event pipeline (`agent-session.ts:598, 728-734`). A hung extension freezes the agent; the changelog records hang-class fixes (pi#5687 background handles, pi#5115 shutdown drains). Mitigations offered are cooperative only (`ctx.signal` AbortSignal, Esc-abort).
No sandbox, documented as a decision: "a partial in-process sandbox would be easy to misunderstand as a security boundary" (`docs/security.md:33-35`); the only gate is project-trust on load.
### 1.5 Design-history record
**Pi has no ADRs** — the Discord recollection that prompted this spike is not substantiated for Pi. Rationale lives in four places: "footgun" sections inside user docs (effectively inline ADRs), a 5,000-line issue-linked changelog (which records abandoned designs: the hooks/customTools split, slash-commands→prompt-templates rename, a fetch-override proxy abandoned for undici dispatchers), Zechner's blog, and issues themselves.
### 1.6 Prompt/context construction
Pi is the only surveyed system that treats **prompt-cache stability as part of the extension API contract**: extensions receive structured prompt inputs (`systemPromptOptions` — the same decomposed inputs Pi itself uses) rather than a final string; per-request context transforms operate on a `structuredClone` so session history is never corrupted; and cache-friendly dynamic tool loading (v0.80.6, pi#6474) activates tools additively via native provider deferred loading (Anthropic `defer_loading`) explicitly to avoid invalidating the prefix cache — with documented warnings about second-order invalidation through prompt-metadata changes (`docs/extensions.md:2254-2290`).
---
## 2. OpenCode (sst/opencode → anomalyco/opencode)
Server (Bun/Effect) + clients (TUI/desktop/web); plugins are npm packages or local TS files loaded in-process, with **two plugin kinds in one package**`server` and `tui` entrypoints, hard rule one-or-the-other (`shared.ts:103-114, 293-295`). A v2 plugin API ships side-by-side with v1 (`/v2/effect`, `/v2/promise` subpaths), designed in a candid 516-line `PLAN.md`.
### 2.1 Hook/event taxonomy
One `Hooks` bag returned by an async factory (`packages/plugin/src/index.ts:74, 222-335`): ~16 mutating `(input, output)` hooks (mutate `output` in place; host reads it back) plus declarative registries (tools, auth, providers) and a single firehose `event` observer. The whole dispatch engine is ~13 lines (`plugin/index.ts:280-293`): sequential, awaited, load-order, later hooks see earlier mutations — ordering documented as a spec ("global config → project config → global plugin dir → project plugin dir"; v2: "plugin registration order, then transform registration order").
**Veto is throw.** The documented idiom for denying a tool call is `throw new Error("Do not read .env files")` (`plugins.mdx:247-257`) — policy denial and plugin bug are indistinguishable in every consumer downstream.
**A typed-but-dead hook.** `permission.ask` — the only hook with real decision semantics (`output.status: "ask" | "deny" | "allow"`) — exists in the published types but has **no dispatch site anywhere in the tree**: a permission-subsystem rewrite orphaned it, types kept compiling, plugins silently no-op'd (oc#7006, open since Jan 2026). This is the sharpest single failure mode found in the whole spike.
### 2.2 Plugin-to-plugin interaction
No dependencies, no registry, no plugin-emitted events. Interaction is (a) blind composition through the shared mutable `output` (last-writer-wins per field) and (b) observing the core event bus. The 2026 core bus itself is heavyweight — event-sourced, durable (SQLite, per-aggregate sequences, idempotent replay), versioned wire types, and a **backpressure guard**: `allBounded` wraps a dropping queue and fails slow subscribers with `SubscriberOverflowError` (`packages/core/src/event.ts:152-164`). Plugins get the untyped tail of a three-tier bridge (Effect streams → global emitter → `event` hook), fired **fire-and-forget** — an async observer's rejection is an unhandled promise rejection invisible to host error routing (`plugin/index.ts:251-258`).
### 2.3 Compatibility strategy
Lockstep versioning (`opencode` = `@opencode-ai/plugin` = `@opencode-ai/sdk` = 1.18.2) plus one gate: npm plugins may declare `engines.opencode` semver ranges, checked at load (`shared.ts:194-205`) — but it is plugin-opt-in, and local file plugins skip it entirely. Three generations of module shape are loaded simultaneously; superseded packages are silently ignored via a hardcoded `DEPRECATED_PLUGIN_PACKAGES` list.
The community-experience record is instructive: v1.14.42 — a **patch release** — removed the whole `api.command.*` TUI namespace with no deprecation cycle (oc#26557); the aftermath is visible in-tree as a deprecated shim added back *after* the outcry ("Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2", `tui.ts:87-120`). No written deprecation policy exists anywhere. Shims-after-outcry is the de facto process.
### 2.4 Failure isolation
Strong at the edges, absent in the middle. Load is staged (`install | entry | compatibility | missing | load`) with per-plugin, per-stage containment and user-visible toasts (`loader.ts:82-93`; `plugin/index.ts:215-249`). Runtime hooks have **no catch and no timeout**: a throw in a tool hook fails that tool call (the sanctioned veto), a throw in chat/transform hooks aborts the turn (session errors, server survives), a **hang hangs the turn forever** — the v2 PLAN explicitly lists "Transform timeouts" under *Deferred Decisions* (`PLAN.md:507-510`). Boot re-entrancy burned them: a plugin calling the SDK client during its own init deadlocked startup (oc#7741). The official troubleshooting page's first advice is "start by disabling plugins."
Two mature exceptions worth stealing: the streaming hot path is protected **structurally** — there is no per-delta hook at all; text hooks fire once at `text-end` (`processor.ts:512-524`) — and the TUI runtime has scope-tracked registrations (a `Proxy`-wrapped keymap API auto-records every registration per plugin, enabling clean live deactivate) plus a **hard 5s dispose budget** racing each cleanup against a timer (`runtime.ts:122-226, 388-468`).
### 2.5 Design-history record
No ADR system; the `PLAN.md` for v2 is the exception and is better than most ADR archives — it names v1's mistakes (the returned-hooks bag, finalizer-triggered special cases), specifies ordering as a contract, splits replayable *transforms* from live *hooks*, and — crucially — carries an honest **Deferred Decisions** section (typed error model, transform timeouts) rather than pretending closure.
### 2.6 Prompt/context construction
Plugins can touch every layer, but the deep layers are gated behind an `experimental.` prefix (`experimental.chat.system.transform`, `experimental.chat.messages.transform`, compaction prompt replacement) — a deliberate two-tier stability promise: interception at operation boundaries is stable, rewriting the context itself is not. No cache-stability discipline comparable to Pi's was found.
---
## 3. Adopt / Adapt / Avoid for Hermes
Grading against #64182's ground rules. "Validated" = the proposal already on the Hermes issue is independently confirmed by field evidence.
| # | Lesson | Verdict | Maps to | Evidence |
|---|---|---|---|---|
| 1 | **Typed per-hook result vocabularies, not veto-by-throw.** Pi's `{block, reason}` / `{cancel}` / `"handled"` enums vs OpenCode's throw-idiom (bug ≡ policy denial) and its dead `permission.ask`. Approval/gate hooks need enumerated results dispatched from the policy engine itself. | **Adopt** | #64162 | Pi runner.ts:759-1188; oc plugins.mdx:247-257, oc#7006 |
| 2 | **Guard hooks fail closed; observers fail open.** Pi contains every handler error except `tool_call`, whose crash blocks the tool with an LLM-visible error result. Independently confirms ground rule 4 — and refines it: the failure mode of a *crashed* security hook must also be closed, not just its config default. | **Adopt** (validated) | #64162, #64204 | Pi agent-session.ts:454-467, agent-loop.ts:657-665 |
| 3 | **Hook wire-up drift is the killer bug class: CI-check that every declared hook has a live dispatch site.** OpenCode's `permission.ask` sat typed-but-dead for 6+ months after a subsystem rewrite. Hermes already stores unknown hook names for forward compat (`register_hook`, plugins.py ~L1158) — the same drift is possible. A `VALID_HOOKS``invoke_hook(` cross-check is a one-file test. | **Adopt now** (cheap, standalone) | #64230 (Doctor), CI | oc index.ts:261 + absent trigger site, oc#7006 |
| 4 | **Deadline budgets on plugin callbacks — be the first framework to have them.** Neither system times out runtime hooks; both shipped hang-class failures (pi#5687/#5115; oc#7741, "Transform timeouts" deferred twice). OpenCode's own TUI dispose path (hard 5s, per-cleanup timer race) proves the mechanism is practical. Observer hooks: enforce a budget and log-and-drop. Mutating/guard hooks: budget + fail per lesson 2. | **Adopt** (differentiator) | #64161, #64164, #64229 | Pi grep: zero timeout logic; oc PLAN.md:507-510, runtime.ts:122-226 |
| 5 | **Per-delta streaming hooks are viable only if non-blocking is structural, not documentary.** The controlled experiment: Pi offers per-delta and awaits inline → slow observer throttles the visible stream, hangs freeze it; OpenCode offers nothing per-delta → hot path safe, TTS use case unserved. #64161's "never-block contract + buffered-queue helper" is the right middle — but make the bounded queue the *only* consumption path (drop/coalesce policy included, cf. OpenCode's `SubscriberOverflowError` dropping queue), not an optional convenience next to a raw sync callback. | **Adapt** | #64161 | Pi agent-session.ts:728-734; oc processor.ts:512-524, event.ts:152-164 |
| 6 | **The namespaced bus proposal is ahead of both systems — proceed, with their two omissions fixed.** Pi's bus works but has arbitrary un-namespaced string channels and no discoverability; OpenCode has no plugin-emit at all. #64164's `<plugin_key>:` enforcement, reserved `hermes:` prefix, advisory declarations, recursion cap, and deterministic subscription order have no counterexample in the field. Carry over per-callback isolation (both systems do this right) and add lesson-4 budgets. Fire-and-forget with return-values-ignored matches both systems' stable practice. | **Adopt own design** (validated) | #64164 | Pi event-bus.ts (whole file); oc plugin/index.ts:251-258 |
| 7 | **Load order as the only priority system; explicit per-registry collision policies.** Zero configuration, deterministic, and no field demand for priorities in either ecosystem. Document Hermes's ordering as a spec the way OpenCode's PLAN does; pick a collision rule per registry (Pi: first-wins tools / suffixed commands / reserved denylist) instead of building dependency resolution. | **Adopt** | #64164, #64229 | Pi loader.ts:660-708, runner.ts:421-604; oc PLAN.md:144-146 |
| 8 | **Host-enforced compat gate + written deprecation window; migration tooling over semver ceremony.** OpenCode's `engines` gate is the right shape but plugin-opt-in only, and its patch-release API removal (oc#26557) shows lockstep versioning without policy is social, not mechanical. Pi shows the complement: loud breaking changes + automatic migrations + alias shims *before* removal. Manifest v2 should carry a host-checked `api_version` range; the repo should carry a one-paragraph deprecation policy. | **Adapt** | #64165, #64179 | oc shared.ts:194-205, tui.ts:87-120, oc#26557; Pi CHANGELOG:243, 3530-3620 |
| 9 | **Scoped registrations with auto-tracked disposal; poison stale contexts with teaching errors.** OpenCode's Proxy-tracked per-plugin scopes (clean live disable) and Pi's post-replacement context poisoning (silent race pi#2860 → loud self-documenting error) are the two halves of a robust lifecycle story — exactly what the #64229 ownership ledger needs. | **Adopt** | #64229 | oc runtime.ts:143-160; Pi runner.ts:514-527, docs:1223-1265 |
| 10 | **Prompt-cache stability as API contract is real and Pi proves it's implementable.** Structured prompt inputs instead of final strings, `structuredClone` for ephemeral transforms, additive-only tool activation with provider deferred loading, documented second-order invalidation warnings. Strongest possible validation of ground rule 2, with a concrete reference implementation for cache-safe injection. | **Adopt** (validated) | #64167 | Pi docs:2254-2290, CHANGELOG 0.80.6/pi#6474 |
| 11 | **Half-sandboxes: both systems refuse, for the same stated reason.** Pi documents that a partial in-process sandbox "would be easy to misunderstand as a security boundary"; OpenCode runs plugins fully privileged with path-containment only. Viable while plugin authors ≈ users; Hermes's Skills-Hub-style trust/scan pipeline is the nearer-term marketplace answer than in-process isolation. | **Adapt with eyes open** | security posture | Pi docs/security.md:5-37; oc shared.ts:89-97 |
| 12 | **Events to plugins before UI and before persistence; boot-stage the plugin-facing client.** Pi's explicit ordering guarantee removes a whole class of races; OpenCode's plugins-as-API-clients design is elegant but deadlocked startup when a plugin called the API mid-init (oc#7741) — if ctx ever grows client-like powers, stage them ("unavailable until ready"). | **Adapt** | #64178, #64229 | Pi agent-session.ts:596-601; oc plugin/index.ts:142-147, oc#7741 |
| 13 | **Neither system has ADRs — and both paid for it.** Pi's rationale is scattered across changelog/blog/footguns; OpenCode broke APIs in patch releases partly because no decision record said not to. Hermes's per-sub-issue design sketches (#64182 style) are already ahead of both; add an explicit **Deferred Decisions** section per design (OpenCode's PLAN.md's best feature) so open questions stay visible instead of silently unresolved. | **Keep + adapt** | process | verified ADR absence in both repos |
## 4. Verified absences (findings, not gaps in the spike)
- **No ADRs in either repo** (repo-wide searches for `adr`/`decision` artifacts). The Discord recollection of "ADRs describing failure modes" is unsubstantiated for both; the nearest equivalents are Pi's docs footgun sections and OpenCode's single v2 PLAN.md.
- **No runtime hook timeouts in either system** (grep-verified in Pi's `src/core/extensions/`; OpenCode's own plan defers them).
- **No written deprecation or plugin-API stability policy in either repo.**
- Caveats: Pi's #2860→remediation causality is inferred from matching failure/fix, not a maintainer statement; OpenCode pre-rewrite history was not diffed (shallow clones); maintainer replies inside cited issues were not visible in fetched content.
---
*Spike time-boxed per #64180. Primary sources: `earendil-works/pi` @ `eb79351` — `packages/coding-agent/src/core/extensions/{runner,loader,types}.ts`, `src/core/{agent-session,event-bus}.ts`, `packages/agent/src/agent-loop.ts`, `docs/{extensions,security}.md`, `CHANGELOG.md`; `anomalyco/opencode` @ `c69abee` — `packages/plugin/src/{index,tui}.ts`, `packages/plugin/src/v2/effect/PLAN.md`, `packages/opencode/src/plugin/{index,shared,loader}.ts`, `packages/opencode/src/plugin/tui/runtime.ts`, `packages/core/src/event.ts`, `packages/web/src/content/docs/plugins.mdx`; issues pi#2860/#2715/#5080/#5687, oc#7006/#26557/#7741/#4850/#12222; mariozechner.at posts (2025-11-02, 2025-11-30).*
+144
View File
@@ -0,0 +1,144 @@
# Plugin Config & State Bridge
**Status:** config + state slice implemented by #64227
**Original design:** Topher Ross (@thebizfixer), RFC PR #58542
**Concrete consumer:** kanban-advanced
## Scope
This slice adds two native `PluginContext` capabilities:
- typed, namespace-jailed settings via `ctx.get_config()` and `ctx.set_config()`;
- atomic, profile-scoped runtime data via `ctx.state`.
Config schema registration, config defaults, and the cron facade from the
original RFC remain separate follow-up work. No core model tool is added.
## Config API
```python
def register(ctx):
endpoint = ctx.get_config("api_url", default="https://example.invalid")
retries = ctx.get_config("retry.attempts", default=3)
ctx.set_config("api_url", "https://api.example.com")
ctx.set_config("retry.attempts", 5)
```
Keys are **relative to the calling plugin**. The example above reads and writes:
```yaml
plugins:
entries:
<effective-plugin-id>:
settings:
api_url: https://api.example.com
retry:
attempts: 5
```
`<effective-plugin-id>` is `manifest.key` when present, otherwise
`manifest.name`. `settings` is the canonical namespace chosen after the issue
discussion in #64227/#67531. For migration safety, reads fall back to the former
`plugins.entries.<id>.config.*` subtree only when the canonical value is absent.
Writes always target `settings`; they do not rewrite or delete legacy values.
### Namespace jail
The API does not accept full config paths. A plugin can never use it to inspect
or change arbitrary Hermes configuration.
Accepted:
```python
ctx.get_config("endpoint")
ctx.set_config("retry.policy", {"attempts": 3})
```
Rejected with `ValueError` and a warning log:
```python
ctx.get_config("security.approval_mode")
ctx.set_config("model.provider", "attacker-proxy")
ctx.set_config("plugins.entries.other.settings.token", "...")
ctx.set_config("../../security.approval_mode", "always_allow")
ctx.set_config(r"..\..\model.provider", "attacker-proxy")
```
There is no global read allowlist: `ctx.profile_name` already exposes the only
small host fact requested by the RFC. Settings writes use Hermes'
profile-aware config loader/saver and atomic YAML replacement. The bridge
validates the existing YAML before writing so malformed config is never
silently replaced. Every operation resolves the active context-local
`HERMES_HOME`, so one globally loaded plugin context follows multiplexed
profile turns without crossing profile data.
## Durable state API
Use state for plugin-owned runtime data such as cursors, dedupe sets, and
caches. Do not put those values in user-owned config.
```python
def register(ctx):
cursor = ctx.state.get("cursor", default={"page": 0})
ctx.state.set("cursor", {"page": cursor["page"] + 1})
```
The facade stores one JSON object at:
```text
<HERMES_HOME>/plugin-data/<plugin-data-namespace>/state.json
```
Portable Agent Plugins use their existing `PLUGIN_DATA` namespace exactly.
Native and nested plugin ids use the same collision-resistant, Windows-safe
namespace algorithm. `ctx.state.data_dir` exposes the directory and
`ctx.state.path` exposes the JSON file when a plugin needs to inspect its own
location.
### State guarantees
- **Profile isolation:** the data root resolves from the active context-local
Hermes home on every operation.
- **Atomic replacement:** state writes use temp-file + `fsync` + `os.replace`.
- **Concurrent updates:** a sibling lock file serializes read-modify-write across
threads and processes (`fcntl` on POSIX, `msvcrt` on Windows).
- **Quota:** the complete serialized state is limited to 10 MiB per plugin. A
rejected update leaves the previous file untouched.
- **Fail closed:** malformed/non-object JSON is reported and never overwritten.
- **Typed values:** values must be JSON-serializable.
State keys are 1128 characters and may contain letters, numbers, `_`, `-`,
`.`, or `:`. Path separators and `..` are rejected.
## State vs. config
| Data | API | Ownership | Example |
|---|---|---|---|
| User-visible behavior | `ctx.get_config` / `ctx.set_config` | User/plugin settings in `config.yaml` | endpoint, timeout, feature mode |
| Runtime bookkeeping | `ctx.state.get` / `ctx.state.set` | Plugin data under `plugin-data/` | cursor, cache, dedupe ids |
Both APIs are additive. Existing plugins that perform their own file I/O keep
working, but new plugins should use this bridge for stable profile and Windows
semantics.
## Verification contract
The implementation is covered with real temporary-Hermes-home tests for:
- fixture-plugin discovery and config/state round trips;
- canonical `settings` writes and legacy `config` read fallback;
- direct global, cross-plugin, POSIX traversal, and Windows traversal rejection;
- concurrent settings writes without lost siblings;
- cross-thread and cross-process state updates;
- atomic quota rejection and malformed-state/config preservation;
- two-profile isolation after the ambient profile changes;
- Unicode and Windows-style path values.
## Related
- [Issue #64227](https://github.com/NousResearch/hermes-agent/issues/64227)
- [RFC PR #58542](https://github.com/NousResearch/hermes-agent/pull/58542) by Topher Ross
- #67531 — standalone plugin settings namespace discussion
+195
View File
@@ -0,0 +1,195 @@
# Network Egress Isolation for Docker Deployments
When running Hermes inside Docker, the default `network_mode: host` gives the
agent process unrestricted outbound network access. This guide shows how to
segment traffic so the agent core can only reach the services it needs, while
blocking arbitrary outbound connections.
This is primarily a defense against prompt injection attacks that attempt to
exfiltrate data via `curl`, `wget`, or raw HTTP from tool-generated shell
commands.
## Threat Model
The Hermes [SECURITY.md](../../SECURITY.md) §2 defines the trust model. The
terminal backend is the primary execution boundary. However, when running with
`network_mode: host`, any command the agent executes can reach any endpoint on
the network, including external ones.
Network egress isolation adds a second layer: even if a malicious command
executes inside the container, it cannot reach endpoints outside the
explicitly allowlisted set.
## Architecture
```
┌─────────────────────────────────────────────┐
│ Docker Network: internal (no internet) │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ hermes-agent │ │ hermes-dashboard │ │
│ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │
│ ▼ │ │
│ ┌──────────────┐ │ │
│ │ hermes-gtw │◄───────────┘ │
│ └──────┬───────┘ │
│ │ │
└──────────┼───────────────────────────────────┘
┌──────────┼───────────────────────────────────┐
│ Docker Network: egress (internet-capable) │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ egress-proxy │──► allowlisted hosts │
│ │ (squid / envoy) │ │
│ └─────────────────┘ │
└──────────────────────────────────────────────┘
```
Two Docker networks:
- **`internal`** — no default route, no internet access. The agent, dashboard,
and gateway run here.
- **`egress`** — has internet access. Only services that need to reach external
APIs are attached to this network.
The gateway service is dual-homed (attached to both networks) so it can
receive inbound messages from Telegram/Slack/etc. and forward them to the
agent on the internal network.
## Compose Configuration
Override the default `docker-compose.yml` with a
`docker-compose.override.yml`:
```yaml
# docker-compose.override.yml
# Network egress isolation for production deployments.
#
# Usage:
# HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d
#
# This overrides network_mode: host with isolated Docker networks.
networks:
internal:
driver: bridge
internal: true # no default route, no internet
egress:
driver: bridge
services:
gateway:
network_mode: "" # clear the host-mode default
networks:
- internal
- egress # needs outbound for Telegram, LLM APIs
ports:
- "127.0.0.1:9119:9119" # dashboard proxy, localhost only
dashboard:
network_mode: ""
networks:
- internal # internal only, no egress needed
```
### With an Egress Proxy (Recommended)
For tighter control, route all outbound traffic through an HTTP proxy with
an explicit allowlist:
```yaml
# docker-compose.override.yml (with egress proxy)
networks:
internal:
driver: bridge
internal: true
egress:
driver: bridge
services:
gateway:
network_mode: ""
networks:
- internal
- egress
environment:
- HTTP_PROXY=http://egress-proxy:3128
- HTTPS_PROXY=http://egress-proxy:3128
- NO_PROXY=hermes,hermes-dashboard,localhost
dashboard:
network_mode: ""
networks:
- internal
egress-proxy:
image: ubuntu/squid:6.10-24.04_edge
networks:
- egress
volumes:
- ./config/squid-allowlist.conf:/etc/squid/conf.d/allowlist.conf:ro
restart: unless-stopped
```
Example `config/squid-allowlist.conf`:
```
# Only allow HTTPS CONNECT to these hosts
acl allowed_hosts dstdomain api.openai.com
acl allowed_hosts dstdomain api.anthropic.com
acl allowed_hosts dstdomain openrouter.ai
acl allowed_hosts dstdomain generativelanguage.googleapis.com
acl allowed_hosts dstdomain api.telegram.org
acl allowed_hosts dstdomain api.github.com
acl allowed_hosts dstdomain discord.com
http_access allow CONNECT allowed_hosts
http_access deny all
```
Adjust the allowlist to match your LLM provider and messaging platform.
## Validating the Setup
After bringing up the stack, verify isolation:
```bash
# From the agent container: this should FAIL (no egress)
docker compose exec gateway \
curl -sf --max-time 5 https://example.com && echo "FAIL: egress not blocked" || echo "OK: egress blocked"
# From the agent container: this should SUCCEED (internal network)
docker compose exec gateway \
curl -sf --max-time 5 http://hermes-dashboard:9119/health && echo "OK: internal reachable" || echo "FAIL"
# If using egress proxy: this should SUCCEED (allowlisted)
docker compose exec gateway \
curl -sf --max-time 5 --proxy http://egress-proxy:3128 https://api.openai.com/v1/models && echo "OK" || echo "FAIL"
```
## Limitations
- **DNS resolution:** The `internal` network can still resolve external DNS
names unless you also run a local DNS resolver that blocks external queries.
For most threat models this is acceptable since DNS resolution alone does not
exfiltrate meaningful data.
- **Not a substitute for sandbox backends:** This guide isolates the agent
*container's* network. If you use the default local terminal backend, tool
commands execute inside the same container. For stronger isolation, combine
network segmentation with a sandboxed terminal backend (Docker, Modal,
Daytona).
- **Platform adapters need egress:** The gateway service needs outbound access
to reach messaging platform APIs. If you add new platform adapters, add their
API endpoints to the proxy allowlist.
## Related
- [SECURITY.md](../../SECURITY.md) — Hermes trust model and vulnerability reporting
- [Terminal backends](../../README.md) — sandboxed execution targets
- [docker-compose.yml](../../docker-compose.yml) — default compose configuration
+687
View File
@@ -0,0 +1,687 @@
# Session Lifecycle
> **Audience:** Gateway developers and maintainers
> **Source files:** `gateway/session.py` (~1444 lines), `gateway/run.py` (~16800 lines), `gateway/config.py`
> **Last updated:** 2026-06-16
## Overview
A **session** represents a continuous conversation between the agent and one or more users on a
messaging platform. The session lifecycle governs when conversations persist, when they reset,
how they survive gateway restarts, and how messages queue during concurrent operations.
The session system lives primarily in two modules:
- `gateway/session.py` — Data model (`SessionSource`, `SessionEntry`, `SessionContext`),
key generation (`build_session_key`), and the main store (`SessionStore`).
- `gateway/run.py` — Gateway runner (`GatewayRunner`) that wires sessions into the message
processing pipeline: session expiry watching, agent caching, restart recovery, and message
queuing.
---
## 1. SessionSource — Message Origin Descriptor
`SessionSource` is a frozen record of *where a message came from*. It is attached to every
incoming `MessageEvent` and used for routing, isolation, and context injection.
### Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `platform` | `Platform` | *(required)* | Enum identifying the messaging platform (telegram, discord, slack, signal, whatsapp, matrix, local, etc.). |
| `chat_id` | `str` | *(required)* | Platform-level chat/group/channel identifier. Routed through the adapter's `chat_id_key` transform. |
| `chat_name` | `Optional[str]` | `None` | Human-readable name of the chat or group. |
| `chat_type` | `str` | `"dm"` | One of `"dm"`, `"group"`, `"channel"`, `"thread"`. Controls session key generation and isolation. |
| `user_id` | `Optional[str]` | `None` | Platform-specific user identifier. Used for authorization and per-user session isolation. |
| `user_name` | `Optional[str]` | `None` | Display name of the message author. Injected into system prompt. |
| `thread_id` | `Optional[str]` | `None` | Forum topic / Discord thread / Slack thread identifier. Differentiates threaded conversations. |
| `chat_topic` | `Optional[str]` | `None` | Channel topic or description (Discord channel topic, Slack channel purpose). |
| `user_id_alt` | `Optional[str]` | `None` | Platform-specific stable alternative ID (Signal UUID, Feishu union_id). Used when `user_id` is ephemeral. |
| `chat_id_alt` | `Optional[str]` | `None` | Signal group internal ID — maps a Signal group V2 identifier to its canonical form. |
| `is_bot` | `bool` | `False` | True when the message author is a bot or webhook (Discord bots). |
| `guild_id` | `Optional[str]` | `None` | Discord guild / Slack workspace / Matrix server scope identifier. |
| `parent_chat_id` | `Optional[str]` | `None` | Parent channel when `chat_id` refers to a thread. |
| `message_id` | `Optional[str]` | `None` | ID of the triggering message. Used for pin/reply/react operations and Discord ID injection. |
| `role_authorized` | `bool` | `False` | True when adapter granted access via a platform role (not individual user ID). |
### Key Methods
- **`description`** (property: `str`) — Human-readable summary e.g. `"DM with Alice"`,
`"group: My Group, thread: 12345"`.
- **`to_dict()` / `from_dict()`** — Serialization round-trip for persistence in `sessions.json`.
---
## 2. SessionEntry — Active Session Record
`SessionEntry` is the per-session metadata record stored in memory and persisted to
`{sessions_dir}/sessions.json`. Each entry maps a `session_key` to its current `session_id`.
### Fields
| Field | Type | Default | Description |
|---|---|---|---|
| `session_key` | `str` | *(required)* | Deterministic key identifying the conversation lane (see §4). |
| `session_id` | `str` | *(required)* | Unique identifier for this specific conversation incarnation. Format: `YYYYMMDD_HHMMSS_<8hex>`. |
| `created_at` | `datetime` | *(required)* | When this session incarnation was created. |
| `updated_at` | `datetime` | *(required)* | Last activity timestamp. Used for idle timeout and expiry checks. |
| `origin` | `Optional[SessionSource]` | `None` | The source that created this session, used for delivery routing. |
| `display_name` | `Optional[str]` | `None` | Chat display name (sourced from `SessionSource.chat_name`). |
| `platform` | `Optional[Platform]` | `None` | Platform enum, persisted for expiry policy lookup across restarts. |
| `chat_type` | `str` | `"dm"` | Chat type, also persisted for policy lookup. |
| `input_tokens` | `int` | `0` | Cumulative LLM input (prompt) tokens consumed. |
| `output_tokens` | `int` | `0` | Cumulative LLM output (completion) tokens consumed. |
| `cache_read_tokens` | `int` | `0` | Cumulative prompt cache read tokens. |
| `cache_write_tokens` | `int` | `0` | Cumulative prompt cache write tokens. |
| `total_tokens` | `int` | `0` | Total token count across all turns. |
| `estimated_cost_usd` | `float` | `0.0` | Estimated cumulative USD cost. |
| `cost_status` | `str` | `"unknown"` | Cost tracking status label. |
| `last_prompt_tokens` | `int` | `0` | Last API-reported prompt token count. Used for accurate compression pre-check. |
### Boolean Flags (State Machine)
SessionEntry has several boolean flags that form a simple state machine governing session
behavior on the next access.
| Flag | Type | Default | Description |
|---|---|---|---|
| `was_auto_reset` | `bool` | `False` | Set when a session was auto-reset due to policy expiry (idle/daily). Consumed once to inject a context notice. |
| `auto_reset_reason` | `Optional[str]` | `None` | `"idle"` or `"daily"` — why the previous session was auto-reset. |
| `reset_had_activity` | `bool` | `False` | Whether the expired session had any messages (`total_tokens > 0`). |
| `is_fresh_reset` | `bool` | `False` | Set by explicit `/new` or `/reset`. Triggers topic/channel skill re-injection on first message. Distinguished from `was_auto_reset` to avoid misleading "session expired" notices. |
| `expiry_finalized` | `bool` | `False` | Set by background expiry watcher after invoking `on_session_finalize` hooks, cleaning tool resources, and evicting the cached agent. Prevents redundant finalization across restarts. |
| `suspended` | `bool` | `False` | Hard force-wipe signal. Set by `/stop` or stuck-loop escalation (3+ consecutive restart failures). On next `get_or_create_session()`, forces a new `session_id` regardless of `resume_pending`. |
| `resume_pending` | `bool` | `False` | Soft recovery marker. Set by `suspend_recently_active()` (crash recovery) or drain timeout. On next access, preserves the existing `session_id` — the user continues on the same transcript. Cleared after the next successful turn completes. |
| `resume_reason` | `Optional[str]` | `None` | Why resume was marked: `"restart_timeout"`, `"shutdown_timeout"`, `"restart_interrupted"`. |
| `last_resume_marked_at` | `Optional[datetime]` | `None` | Timestamp of the last resume-pending marking. |
### State Transition Logic (get_or_create_session)
```
┌──────────┐
│ Incoming │
│ Message │
└────┬─────┘
┌──────────────────────┐
│ session_key exists │──── No ──► Create fresh SessionEntry
│ AND !force_new │
└──────────┬───────────┘
│ Yes
┌──────────────────────┐
│ entry.suspended? │──── Yes ──► Auto-reset: new session_id
└──────────┬───────────┘ (reason="suspended")
│ No
┌──────────────────────┐
│ entry.resume_pending?│──── Yes ──► Return existing entry
└──────────┬───────────┘ (preserve session_id)
│ No Clear flag on next successful turn
┌──────────────────────┐
│ Policy says reset? │──── Yes ──► Auto-reset: new session_id
└──────────┬───────────┘ (reason="idle"/"daily")
│ No
┌──────────────────────┐
│ Return existing │
│ entry, bump │
│ updated_at │
└──────────────────────┘
```
**Priority order in `get_or_create_session()`:**
1. `suspended=True` → always force-reset (hard wipe)
2. `resume_pending=True` → preserve session_id (soft recovery)
3. Policy expiry (idle/daily) → auto-reset
4. No trigger → return existing entry (bump `updated_at`)
---
## 3. SessionStore — Storage and Operations
`SessionStore` is the main storage layer. It maintains an in-memory dict (`_entries`) persisted
to `sessions.json`, with SQLite (`SessionDB`) as the canonical store for session metadata and
message transcripts.
### Constructor
```python
SessionStore(sessions_dir: Path, config: GatewayConfig, has_active_processes_fn=None)
```
- `sessions_dir` — Directory where `sessions.json` lives.
- `config``GatewayConfig` instance for reset policy lookups.
- `has_active_processes_fn` — Optional callback keyed by `session_key` to check for running
background processes. Sessions with active processes are never expired or pruned.
### Operations (Methods)
| Method | Description |
|---|---|
| `get_or_create_session(source, force_new=False)` | Core entry point. Returns existing or creates new `SessionEntry`. Evaluates `suspended`, `resume_pending`, and reset policy. Creates/ends SQLite records. |
| `update_session(session_key, last_prompt_tokens=None)` | Lightweight metadata update after an interaction. Bumps `updated_at`, optionally records `last_prompt_tokens`. |
| `reset_session(session_key, display_name=None)` | Explicit reset (from `/new` or `/reset`). Creates new `session_id`, sets `is_fresh_reset=True`. Ends old SQLite session, creates new one. |
| `switch_session(session_key, target_session_id)` | Switch to a different existing session ID (from `/resume`). Ends current SQLite session, reopens target. |
| `suspend_session(session_key)` | Mark session as `suspended=True` (from `/stop`). Forces auto-reset on next access. |
| `mark_resume_pending(session_key, reason)` | Mark session as `resume_pending=True` (from drain timeout). Preserves session_id on next access. Will NOT override `suspended=True`. |
| `clear_resume_pending(session_key)` | Clear `resume_pending` after a successful resumed turn. Called from gateway after `run_conversation()` returns. |
| `suspend_recently_active(max_age_seconds=120)` | Crash recovery: mark recently-active sessions as `resume_pending=True`. Skips already-pending and already-suspended entries. Called on startup after unclean shutdown. |
| `prune_old_entries(max_age_days)` | Drop entries older than `max_age_days` (based on `updated_at`). Skips `suspended` entries and sessions with active processes. |
| `list_sessions(active_minutes=None)` | Return all sessions, optionally filtered by recent activity. Sorted by `updated_at` descending. |
| `lookup_by_session_id(session_id)` | Find the active `SessionEntry` for a persisted session ID. |
| `has_any_sessions()` | Check if any sessions have ever been created (uses SQLite for history, not just in-memory dict). |
| `append_to_transcript(session_id, message, skip_db=False)` | Append a message to SQLite transcript. `skip_db=True` prevents duplicate writes when the agent already persisted. |
| `rewrite_transcript(session_id, messages)` | Full replacement of session transcript (used by `/retry`, `/undo`, `/compress`). |
| `load_transcript(session_id)` | Load all messages from a session's SQLite transcript. |
| `rewind_session(session_id, n=1)` | Back up `n` user turns via soft-delete (keeps audit trail). Returns `{rewound_count, turns_undone, target_text}`. |
### Internal Helpers
- `_ensure_loaded()` / `_ensure_loaded_locked()` — Load `sessions.json` into `_entries` dict.
- `_save()` — Atomic write to `sessions.json` via temp file + `atomic_replace`.
- `_generate_session_key(source)` — Delegates to `build_session_key()` with config params.
- `_is_session_expired(entry)` — Policy check from entry alone (no source needed). Used by
background expiry watcher.
- `_should_reset(entry, source)` — Policy check returning `"idle"`, `"daily"`, or `None`.
### Storage Layout
```
{sessions_dir}/
sessions.json # In-memory _entries dict, persisted as JSON
Maps session_key → SessionEntry (metadata only)
{session_id}.jsonl # (Legacy, removed in spec 002)
```
The canonical transcript store is SQLite via `SessionDB` (from `hermes_state`). The
`sessions.json` file persists the `session_key → session_id` mapping and entry metadata
(flags, timestamps, token counts). If SQLite is unavailable, the store falls back to
JSONL, but this is a degradation path.
---
## 4. SessionKey Generation Rules
Session keys are deterministic strings that identify a conversation lane. They are generated
by `build_session_key(source, group_sessions_per_user, thread_sessions_per_user)`.
### Key Format
```
agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]
```
### DM Rules
| Scenario | Key |
|---|---|
| DM with chat_id | `agent:main:telegram:dm:12345` |
| DM with chat_id + thread | `agent:main:telegram:dm:12345:thread_678` |
| DM without chat_id, with participant_id | `agent:main:signal:dm:user_abc` |
| DM without chat_id or participant_id | `agent:main:telegram:dm` |
| WhatsApp DM (canonicalized) | `agent:main:whatsapp:dm:{canonical_number}` |
- DMs always include `chat_id` when present, isolating each private conversation.
- `thread_id` further differentiates threaded DMs within the same DM chat.
- Without `chat_id`, falls back to `user_id_alt` or `user_id` as participant_id.
- Without any identifier, all DMs on that platform collapse to one shared session.
### Group/Channel Rules
| Scenario | Key |
|---|---|
| Group chat | `agent:main:telegram:group:-10012345` |
| Group chat, per-user isolation | `agent:main:telegram:group:-10012345:user_abc` |
| Thread in group, shared | `agent:main:discord:group:12345:thread_678` |
| Thread in group, per-user | `agent:main:discord:group:12345:thread_678:user_abc` |
| Channel | `agent:main:slack:channel:C12345` |
| WhatsApp group (canonicalized) | `agent:main:whatsapp:group:{canonical_id}:{participant}` |
- `chat_id` identifies the parent group/channel.
- `thread_id` differentiates threads within that parent.
- **Per-user isolation** (append `participant_id`) is controlled by:
- `group_sessions_per_user` (default: `True`) — group/channel sessions are isolated.
- `thread_sessions_per_user` (default: `False`) — threads are **shared** by default
(Telegram forum topics, Discord threads, Slack threads all share one session per thread).
- `participant_id` = `user_id_alt` or `user_id` (in that priority).
- WhatsApp identifiers are canonicalized to handle JID/LID alias flips.
### Special Case: WhatApp
WhatsApp phone numbers go through `canonical_whatsapp_identifier()` which strips the
`@s.whatsapp.net` suffix and normalizes to E.164 format. This prevents session fragmentation
when the bridge returns different alias forms of the same phone number.
---
## 5. Multi-User Isolation Strategy
Multi-user isolation determines whether multiple users in the same chat share a conversation
or each get their own private session.
### Decision Logic (`is_shared_multi_user_session`)
```python
def is_shared_multi_user_session(source, *, group_sessions_per_user, thread_sessions_per_user):
if source.chat_type == "dm":
return False # DMs are always private
if source.thread_id:
return not thread_sessions_per_user # Threads: shared unless per-user
return not group_sessions_per_user # Groups: isolated unless shared
```
### Summary
| Chat Type | Default | Config Control |
|---|---|---|
| DM | Private (never shared) | N/A |
| Group/Channel | Per-user isolation | `group_sessions_per_user` (default: True) |
| Thread (forum, discord) | Shared (all participants see same context) | `thread_sessions_per_user` (default: False) |
### Impact on System Prompt
When `shared_multi_user_session=True`, the system prompt omits a fixed user name and instead
states: *"Multi-user {thread|session} — messages are prefixed with [sender name]. Multiple
users may participate."* Individual sender names are prefixed on each user message by the
gateway at runtime, preserving prompt caching (the system prompt doesn't change per-turn).
---
## 6. Reset Policy
Reset policies control when a session automatically loses context (gets a new `session_id`).
### Policy Modes (`SessionResetPolicy`)
| Mode | Behavior | Default Config |
|---|---|---|
| `"none"` | Never auto-reset. Context managed only by compression. | — |
| `"idle"` | Reset after N minutes of inactivity from `updated_at`. | `idle_minutes: 1440` (24h) |
| `"daily"` | Reset at a specific hour each day (local time). | `at_hour: 4` (4 AM) |
| `"both"` | Whichever triggers first — daily boundary OR idle timeout. | **(default)** |
### Policy Evaluation
```python
# Idle check
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
if now > idle_deadline: return "idle"
# Daily check
today_reset = now.replace(hour=policy.at_hour, minute=0, second=0, microsecond=0)
if now.hour < policy.at_hour:
today_reset -= timedelta(days=1) # Reset hasn't happened yet today
if entry.updated_at < today_reset: return "daily"
```
### Per-Platform/Per-Type Policies
Reset policies are configurable per platform and session type via `config.get_reset_policy()`.
This allows different platforms to have different expiry rules (e.g., Telegram DMs reset
after 24h idle, but Slack groups persist indefinitely).
### Exclusions
Sessions with active background processes are **never** expired or reset. The
`has_active_processes_fn` callback checks for running processes when evaluating policies.
### Reset Effects
When a reset triggers:
1. Old session is ended in SQLite (with reason `"session_reset"`).
2. New `session_id` is generated (`YYYYMMDD_HHMMSS_<8hex>`).
3. New `SessionEntry` is created with `was_auto_reset=True` and the reset reason.
4. `reset_had_activity` is set if the old session had any turns (`total_tokens > 0`).
5. The old AIAgent cache entry is evicted on the next expiry watcher pass.
6. On the first message after reset, a context notice is injected: "Session expired due to inactivity / daily reset."
---
## 7. Restart Recovery Flow
The restart recovery system ensures that in-flight sessions are preserved across gateway
restarts, crashes, and drain timeouts. It is the solution to issue #7536.
### Startup Recovery Sequence
```
Gateway starts
┌───────────────────────────────┐
│ Check for .clean_shutdown │── Exists? ──► Skip suspension (clean exit)
│ marker │
└───────────────────────────────┘
│ Missing
┌───────────────────────────────┐
│ session_store │── Marks sessions updated within
│ .suspend_recently_active() │ last 120 seconds as resume_pending
└───────────────────────────────┘
┌───────────────────────────────┐
│ _suspend_stuck_loop_sessions()│── Suspends sessions that have been
│ │ active across 3+ restarts
└───────────────────────────────┘
┌───────────────────────────────┐
│ Queue inbound messages while │
│ startup restore runs │
│ (_startup_restore_in_progress)│
└───────────────────────────────┘
┌───────────────────────────────┐
│ For each adapter, find │
│ resume_pending sessions → │
│ synthesize MessageEvent and │
│ run _handle_message to let │
│ the agent auto-continue │
└───────────────────────────────┘
```
### suspend_recently_active(max_age_seconds=120)
Called on gateway startup when no `.clean_shutdown` marker exists (indicating a crash or
unexpected exit). For each session updated within the last 120 seconds:
- Sets `resume_pending=True`, `resume_reason="restart_interrupted"`,
`last_resume_marked_at=now`.
- Skips entries already `resume_pending=True` (no double-mark).
- Skips entries explicitly `suspended=True` (hard wipe should stay).
### Stuck-Loop Detection (`_suspend_stuck_loop_sessions`)
Counts consecutive restarts via a JSON file (`{HERMES_HOME}/restart_counts.json`). If a
session has been active across 3+ consecutive restarts, it's auto-suspended so the user
gets a clean slate.
### Drain-Timeout Marking
On graceful shutdown/restart, the drain system calls `mark_resume_pending()` for any
session that was mid-turn when the drain timeout fired. Reasons:
- `"restart_timeout"` — killed during restart drain
- `"shutdown_timeout"` — killed during shutdown drain
- `"restart_interrupted"` — crash recovery (from `suspend_recently_active`)
All three reasons are in `_AUTO_RESUME_REASONS` and eligible for startup auto-resume.
### Auto-Resume on Next Access
When `get_or_create_session()` encounters `resume_pending=True`:
1. It returns the existing entry **without** creating a new `session_id`.
2. The existing transcript is loaded intact.
3. The marking is not cleared here — it survives until the next successful turn
completes (`clear_resume_pending()` is called from the gateway after
`run_conversation()` returns a real response).
4. If the resumed turn is interrupted again, the `resume_pending` flag remains set,
and the next restart will retry. The stuck-loop counter handles terminal escalation
(3 retries → suspended).
### Clean Shutdown Marker (`.clean_shutdown`)
Written at the end of a graceful shutdown. On next startup:
- If present: skip `suspend_recently_active()` entirely. Active agents were already
drained, so no sessions are stuck.
- Then delete the marker.
This prevents unwanted auto-resets after `hermes update`, `hermes gateway restart`,
or `/restart`.
---
## 8. Message Queuing Flow
The message queuing system handles two scenarios:
1. **Interrupt follow-ups** — When a user sends multiple messages while the agent is
processing, subsequent messages are queued as single-slot pending messages.
2. **`/queue` FIFO** — Explicit `/queue` commands that must each produce their own full
agent turn, in order, without merging.
### Data Structures
```
adapter._pending_messages: Dict[session_key, MessageEvent]
└── Single "next-up" slot per session. Overwritten on repeat sends
(burst collapse). Shared with photo-burst follow-ups.
self._queued_events: Dict[session_key, List[MessageEvent]]
└── Overflow buffer. Each /queue invocation appends here when the
slot is occupied. Promoted one-at-a-time after each drain.
```
### Enqueue (`_enqueue_fifo`)
```
_enqueue_fifo(session_key, event, adapter)
┌───────────────────────────────────────┐
│ Is slot free? │
│ (session_key NOT in _pending_messages)│── Yes ──► Place event in slot
└───────────────────────────────────────┘
│ No
Append to _queued_events[session_key] (overflow tail)
```
### Dequeue / Promotion (`_promote_queued_event`)
Called at the drain site after the slot was consumed. If there's an overflow item:
- When `pending_event is None` (slot was empty), return overflow head as the new event.
- When `pending_event` exists, stage overflow head in the slot for the next recursion.
- If no adapter available, push back to `_queued_events` (don't silently drop).
### Queue Depth
`_queue_depth(session_key, adapter)` returns `len(overflow) + (1 if slot occupied else 0)`.
### Clearing
Queued events for a session are cleared on `/new` and `/reset` (via `_handle_reset_command`).
### FIFO Invariant
Each `/queue` invocation produces exactly one full agent turn, in FIFO order, with no
merging. The single-slot `_pending_messages` + overflow `_queued_events` design ensures
that repeated sends during an active turn don't cause out-of-order processing.
---
## 9. Session Context Injection
`SessionContext` is built from a `SessionSource` and `GatewayConfig` and injected into the
agent's system prompt. It tells the agent:
- Where the current message came from
- What platforms are connected
- Where it can deliver scheduled task outputs
- Whether this is a shared multi-user session
### Construction (`build_session_context`)
```python
def build_session_context(source, config, session_entry=None) -> SessionContext
```
1. Collects connected platforms from config.
2. Collects home channels for each platform.
3. Determines `shared_multi_user_session` via `is_shared_multi_user_session()`.
4. Attaches session metadata (key, id, timestamps) if `session_entry` is provided.
### PII Redaction (`build_session_context_prompt`)
The dynamic system prompt section (`## Current Session Context`) can optionally redact
personally identifiable information before sending to the LLM:
- User IDs → `user_<12hex>` (SHA-256 prefix)
- Chat IDs → `<platform>:<12hex>` or just `<12hex>`
- Platforms excluded from redaction: Discord (needs raw IDs for `@mentions`),
and any plugin-registered platform not marked `pii_safe`.
Redaction applies only to the system prompt text. Routing, session keys, and adapter
operations always use the original values.
---
## 10. Background Expiry Watcher
The `_session_expiry_watcher` task runs in the gateway event loop every 300 seconds (5 min).
### Responsibilities
1. **Finalize expired sessions** — For each entry where `_is_session_expired()` returns
True and `expiry_finalized` is False:
- Invoke `on_session_finalize` plugin hooks (cleanup, notifications).
- Clean up cached AIAgent resources (close tool resources, shut down memory provider).
- Evict the cached agent entry.
- Clear per-session overrides (`_session_model_overrides`, reasoning overrides, etc.).
- Mark `expiry_finalized=True` and persist (sessions.json + state.db).
- Promote the state.db session row to `end_reason='session_reset'` via
`promote_to_session_reset()` — conditional: only live rows or rows ended with a
recoverable accidental reason (`agent_close`, `ws_orphan_reap`) are promoted, so
explicit boundaries (`compression`, `session_switch`, …) are never overwritten. This
durably records the reset so stale-route recovery cannot resurrect the expired
session with its full history (#61220, #61993, #63539).
2. **Sweep idle cached agents** — Calls `_sweep_idle_cached_agents()` to evict agents that
have been idle beyond the idle TTL (3600s / 1h by default), regardless of session
reset policy. This prevents unbounded memory growth in gateways with long-lived sessions.
3. **Sweep under memory pressure** — Calls `_sweep_agent_cache_under_pressure()` to shed
least-recently-used transcripts once the process's anonymous RSS is over budget. See
§11.
4. **Prune stale entries** — Calls `session_store.prune_old_entries()` hourly based on
`config.session_store_max_age_days`. Prevents `sessions.json` from growing unbounded.
### Failure Handling
- Per-session retry count: each failed finalize is retried up to 3 consecutive times.
- After 3 failures, the entry is force-marked `expiry_finalized=True` to prevent infinite
retry loops.
---
## 11. Agent Cache
The gateway maintains an LRU cache of `AIAgent` instances keyed by `session_key` to
preserve prompt caching across turns.
### Cache Properties
- **Max size:** 128 entries (`agent.agent_cache.max_size`, default `_AGENT_CACHE_MAX_SIZE`).
- **Eviction policy:** Least-recently-used (LRU via `OrderedDict`).
- **Idle TTL:** 3600s (1h) — `agent.agent_cache.idle_ttl_secs`, enforced by
`_session_expiry_watcher`.
- **Memory budget:** `agent.agent_cache.memory_high_mb` (default `auto`) — see below.
- **Lock:** `_agent_cache_lock` (threading) for thread safety.
### Memory-Pressure Eviction
A cached agent pins `_session_messages`, the full live transcript including tool
outputs — tens of MB on a session with 100+ tool calls. The entry cap and the idle
TTL are both blind to that: a gateway serving many chats keeps every warm transcript
resident (agents that took a turn within the TTL are never idle-swept, and the idle
sweep additionally defers finalizable sessions until they expire), so RSS climbs until
the cgroup throttles and SIGTERM can no longer flush inside systemd's stop timeout
(#80764).
`_sweep_agent_cache_under_pressure()` is the valve. Each watcher tick it compares the
process's anonymous RSS against `memory_high_mb`; over budget, it evicts LRU agents
through the same soft path the cap enforcer uses (`_commit_then_release_soft`), then
runs `malloc_trim` so the freed arenas actually return to the OS. Evicted sessions
rebuild their transcript from the persisted session on the next turn.
Three classes of session are never shed:
- agents currently mid-turn (their clients and sandboxes are in use);
- the `protect_recent` most-recently-used sessions (their prompt cache is worth the
most);
- any session whose live transcript has not finished reaching disk —
`transcript_persistence_caught_up()` compares `_last_flushed_db_idx` against
`len(_session_messages)`, the same divergence the FTS write-corruption guard reacts
to when it preserves live history over a lagging transcript.
`memory_high_mb: auto` derives the budget from the cgroup limit the gateway runs under
(`memory.high`, then `memory.max`, then cgroup v1), falling back to total RAM when
uncapped. Set a number to pin it, or `0`/`off` to disable the pass entirely. Helpers
live in `gateway/agent_cache_pressure.py`.
### Cache Lifecycle
```
Message arrives
get_or_create_session() → session_key obtained
Lookup _agent_cache[session_key]
├── Hit → move_to_end(), reuse AIAgent (preserves prompt cache)
└── Miss → create new AIAgent, store in cache
(if at capacity, popitem(last=False) evicts LRU entry)
run_conversation() → agent processes message
Session expiry watcher evicts agent when session finalizes
```
### Cleanup Flow
When a session expires:
1. `_cleanup_agent_resources(agent)` — shuts down memory provider, closes tool resources.
2. `_evict_cached_agent(key)` — removes from `_agent_cache` so the agent can be GC'd.
---
## Appendix: Key Configuration
| Config Key | Type | Default | Description |
|---|---|---|---|
| `group_sessions_per_user` | `bool` | `true` | Isolate group/channel sessions per user |
| `thread_sessions_per_user` | `bool` | `false` | Isolate thread sessions per user |
| `session_store_max_age_days` | `int` | `0` | Prune sessions older than N days (0=disabled) |
| `agent.gateway_auto_continue_freshness` | `int` | `3600` | Seconds for resume freshness window |
| `agent.gateway_timeout` | `int` | `1800` | Agent turn timeout (30 min default) |
| `agent.agent_cache.max_size` | `int` | `128` | LRU entry cap on cached AIAgents |
| `agent.agent_cache.idle_ttl_secs` | `int` | `3600` | Evict agents idle this long |
| `agent.agent_cache.memory_high_mb` | `int`/`str` | `auto` | Anon-RSS budget above which LRU transcripts are shed |
| `agent.agent_cache.max_evictions_per_pass` | `int` | `16` | Cap on sessions shed per pressure pass |
| `agent.agent_cache.protect_recent` | `int` | `8` | MRU sessions the pressure pass never touches |
## State database and FTS recovery
The canonical transcript lives in the `sessions` and `messages` tables. FTS5
tables and their sync triggers are derived indexes that can be detached and
rebuilt without deleting canonical messages. See
[`docs/state-db-recovery.md`](state-db-recovery.md) for the bounded live failure
mode and the explicit repair procedure.
### Reset Policy (per-platform/type, in config.yaml)
```yaml
session_reset:
mode: none # none (default) | idle | daily | both
at_hour: 4 # daily reset hour (local time)
idle_minutes: 1440 # idle timeout (24h)
notify: true # notify user on auto-reset
```
Platform-specific overrides can be set under `platforms.<name>.session_reset`.
+102
View File
@@ -0,0 +1,102 @@
# State database and FTS recovery
`state.db` stores two different data classes:
- `sessions` and `messages` are the canonical transcript.
- `messages_fts*` tables and their sync triggers are derived search indexes.
The derived indexes may be detached temporarily. They must not turn a live
message write or search into an unbounded full-transcript rebuild.
## Live behavior when FTS is corrupt
If an FTS write or search reports the corruption error class, `SessionDB`:
1. records the durable `fts_stale` marker;
2. removes the FTS sync triggers in the same transaction;
3. retries canonical writes without the derived-index sinks; and
4. serves searches from canonical rows through the `LIKE` fallback.
The failing live operation never runs `FTS5('rebuild')`. Existing recovery
ownership remains unchanged: a later `SessionDB` open may rebuild under the
cross-process admission lock and foreign-holder guard. If that guarded rebuild
cannot run, FTS remains detached, canonical writes stay available, and
`hermes doctor` reports the explicit repair command.
## Live behavior when the file itself is corrupt
If a live write reports bare `SQLITE_CORRUPT` / `SQLITE_NOTADB` (`database
disk image is malformed`, `file is not a database`) with no FTS provenance,
the damage is in a canonical B-tree, the schema, or the freelist. `SessionDB`
then quarantines that handle (`StateDbCorruptError`):
1. the failing write propagates the typed error and nothing is retried;
2. later writes on the handle fail immediately without touching the file;
3. the handle never reopens its connection after `close()`; and
4. `close()` skips its explicit WAL checkpoint.
Stopping the writes is the protection. In the field, a handle that kept
writing for ~50 minutes after the first structural error checkpointed 15
pages under the wrong page numbers on shutdown (page 1 received a
`messages_fts_trigram_data` leaf) and turned a damaged-but-readable file into
one that no longer opened at all. Skipping the explicit checkpoint is the
second line of defence; on Python 3.12+ the quarantine also disables
SQLite's own last-connection checkpoint (`SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE`),
so the `-wal` sidecar survives `close()` for forensics. On Python 3.11 that
switch is unavailable and SQLite may still checkpoint once on close, so copy
`state.db`, `state.db-wal` and `state.db-shm` together before restarting
anything.
The gateway and the agent flush path treat the quarantine like a replaced
file: pending transcripts go to `sessions/<id>.jsonl` and the gateway
`pending_messages/` spool instead of the retry queue, and the FTS one-shot
rebuild never runs on the damaged file. The quarantine is per process — the
shared handle stays poisoned for every holder until the process restarts on a
repaired or restored file. Do not run `hermes doctor --fix` while the gateway
is still up. Next steps:
```bash
hermes gateway stop
HERMES_HOME="$HOME/.hermes" hermes sessions recover --source "$HOME/.hermes/state.db" --inspect-only
# if recoverable:
HERMES_HOME="$HOME/.hermes" hermes sessions recover --source "$HOME/.hermes/state.db" --output "$HOME/recovered-state.db"
```
or restore the newest snapshot from `state-snapshots/`.
## Explicit repair
Stop every process that can open the profile database before repairing it.
Keep them stopped for the complete repair and verification window.
```bash
hermes gateway stop
HERMES_HOME="$HOME/.hermes" hermes sessions repair --check-only
HERMES_HOME="$HOME/.hermes" hermes sessions repair
```
`sessions repair` creates a SQLite backup by default and performs structural
work through the repository's guarded snapshot-and-promotion path. Do not copy
`state.db`, `state.db-wal`, and `state.db-shm` independently with `cp`; those
files are one live SQLite image.
After repair, verify the health probe, stale marker, trigger set, and canonical
row counts before restarting the gateway:
```bash
HERMES_HOME="$HOME/.hermes" hermes sessions repair --check-only
sqlite3 "$HOME/.hermes/state.db" \
"SELECT key, value FROM state_meta WHERE key = 'fts_stale';"
sqlite3 "$HOME/.hermes/state.db" \
"SELECT type, name FROM sqlite_master WHERE name IN
('messages_fts_insert','messages_fts_update','messages_fts_delete')
ORDER BY name;"
sqlite3 "$HOME/.hermes/state.db" \
"SELECT 'sessions', COUNT(*) FROM sessions
UNION ALL SELECT 'messages', COUNT(*) FROM messages;"
```
The marker query should return no row, the expected FTS triggers should be
present, and canonical row counts must not decrease. If repair fails, preserve
both the live database and the reported backup; never delete canonical rows to
make a derived-index error disappear.
+93
View File
@@ -0,0 +1,93 @@
# Streaming TTS
Hermes can stream TTS audio as it arrives from the provider, instead of waiting
for the full audio before playing. This is used by voice mode (CLI/TUI live
conversation), the dashboard speak-stream WebSocket, and — via the gateway
`StreamingTTSConsumer` — any platform adapter that opts into streaming audio.
Voice replies start speaking after the first clause instead of after full
generation + synthesis.
## Architecture
The streaming pipeline has four parts:
1. **Producer** — the LLM emits text deltas as it generates a response
2. **Sentence chunker**`tools.tts_streaming.SentenceChunker` accumulates
deltas, strips `<think>` blocks (even split across deltas), and flushes
complete sentences
3. **TTS provider** — a registered `StreamingTTSProvider` turns each sentence
into raw PCM chunks (int16 mono at the provider's declared `sample_rate`)
4. **Audio sink**`sounddevice.OutputStream` for local playback
(`tools.tts_tool.stream_tts_to_speaker`), or a gateway platform adapter's
`write_streaming_tts` seam (`gateway/streaming_tts_consumer.py`)
Providers with no chunked API still get per-*sentence* playback via the proven
sync `text_to_speech_tool` path, so edge (the default) is conversational too.
All spoken text is cleaned by `tools.tts_text_normalize.prepare_spoken_text`
(one cleaner, all paths).
## How to pick a provider
By default the dispatcher streams with the provider you already configured
(`tts.provider`) when that provider has a chunked API — it never silently
swaps your voice for a different provider just to get streaming.
To override, set `tts.streaming.provider` in your `config.yaml`:
- a provider name (`elevenlabs`, `gemini`, `openai`, `xai`) pins that streamer
- `auto` walks the priority list `elevenlabs → gemini → openai → xai` and uses
the first one whose credentials resolve — an explicit opt-in to "best
chunked voice available"
```yaml
tts:
provider: gemini
streaming:
provider: gemini # or "auto"
gemini:
model: gemini-2.5-flash-preview-tts
voice: Kore
```
## Capability matrix
| Provider | Transport | Chunked PCM | Credentials |
|-------------|---------------------------------------|-------------|-------------|
| elevenlabs | chunked HTTP (`pcm_24000`) | yes | `ELEVENLABS_API_KEY` / `tts.elevenlabs` |
| openai | chunked HTTP (`with_streaming_response`, `pcm`) | yes | `tts.openai.api_key` → env → managed gateway |
| gemini | SSE (`streamGenerateContent?alt=sse`) | yes | `GEMINI_API_KEY` / `GOOGLE_API_KEY` |
| xai | WebSocket (`wss://api.x.ai/v1/tts`) | yes | xAI OAuth or `XAI_API_KEY` |
| edge, piper, kitten, neutts, mistral, minimax, deepinfra, … | — | no (per-sentence sync fallback) | as usual |
All credential lookups go through `resolve_provider_secret()`
(config > env/.env > credential pool) — never bare env reads. Streamed bodies
are capped at 16 MiB per sentence, mirroring the sync providers' bounded
upstream-body invariant.
## Adding a new streaming provider
1. Subclass `StreamingTTSProvider` in `tools/tts_streaming.py`
2. Set `sample_rate` (and `channels` / `sample_width` if not int16 mono)
3. Implement `available()` (a pure probe — never install anything) and
`stream(self, text) -> Iterator[bytes]` yielding raw PCM chunks
4. Decorate with `@register("yourname")`
5. Add tests in `tests/tools/test_tts_streaming.py`
The ABC enforces the contract; the registry makes the provider discoverable;
the dispatcher (`stream_tts_to_speaker`) and the gateway consumer handle the
sentence buffer, stop events, and audio sink for free.
## Gateway streaming (platform adapters)
`gateway/streaming_tts_consumer.py` bridges agent deltas to an adapter's
streaming-audio seam. Adapters opt in by overriding, on
`BasePlatformAdapter`:
- `supports_streaming_tts(chat_id, audio_format) -> bool`
- `begin_streaming_tts / write_streaming_tts / finish_streaming_tts /
abort_streaming_tts`
All default to unsupported/no-op, so existing adapters are untouched. When a
turn's streaming audio completes, the whole-file auto-TTS reply for that turn
is suppressed (no double playback); when streaming fails before any audio was
audible, the gateway falls back to the legacy whole-file voice reply.