7582 Commits
Author SHA1 Message Date
unsupportedpastels 8987ae565f fix(gateway): owner names a managed approvals.mode when it refuses the write
approvals.mode and yolo with scope=global were already refused when the
managed scope pins approvals.mode (set_config_value refuses it), but the
owner reported storage_unavailable. It now reports permission_denied with
field=approvals.mode, like the other owner config.set refusals. Nothing is
written either way, and any other failure keeps storage_unavailable.
2026-11-05 18:27:43 -05:00
unsupportedpastels 2dc7afd82e fix(gateway): owner refuses a global reasoning level when agent.reasoning_effort is managed
config.set reasoning <level> with scope=global saved agent.reasoning_effort
through _save_gateway_config_key, which has no managed check, so a pinned
effort was written and the call answered success. It is now refused with
permission_denied naming the key before the session's level is committed
or anything is saved, as the sidecar's config.set already refuses it.
Display words, reset and session scope are unchanged.
2026-11-05 18:27:43 -05:00
unsupportedpastels c2e11d9383 fix(gateway): run each owner config edit once, checking the leaves it declares
The managed-key check re-ran the edit on a copy with the pinned leaves
removed to find out what it writes, so every edit had to be safe to
repeat (focus reads existing values to decide what to write). Each edit
now returns the dotted leaves it sets or clears along with its result;
_write_profile_config runs it once, refuses when one of those leaves is
pinned and only then saves. Setting a pinned leaf to the value the raw
file already holds is still refused; focus status declares no leaf.
2026-11-05 18:27:43 -05:00
unsupportedpastels c4c0003ed2 fix(tui_gateway): refuse a managed reasoning effort before a global model pick changes anything
A global /model pick with --reasoning also writes agent.reasoning_effort. When only that key was
administrator-managed, the immediate path switched the live model, pinned the session override and
persisted the model before refusing the effort (4030 after a partial success), and the mid-turn
path queued the pick. Both paths now check the effort key with the model keys, before any change.

The shared owner's config.set compared only leaves whose raw value changed, so setting a managed
skin to the value the raw file already held answered success while the managed value still won.
It now refuses any write that targets a managed leaf; read-only requests are unaffected.
2026-11-05 18:27:43 -05:00
unsupportedpastels c68c48301d fix(gateway): shared owner's config.set refuses administrator-managed keys
The shared owner serves the TUI's config.set from
gateway/session_tui_config.py, which wrote the owning profile's
config.yaml with no managed-key check: `config.set skin mono` with
display.skin pinned saved the value and answered success while every
read kept the administrator's value.

_write_profile_config now compares the edit against the managed scope's
pinned leaves (managed_scope.managed_config_keys, the set
is_key_managed reads) and refuses a change to any of them as
permission_denied naming the key, before anything is written. The
personality write, which goes through persist_personality instead, gets
the same check. approvals.mode already goes through set_config_value,
which refuses managed keys.

Tests cover both TUI paths: a pinned key is refused with nothing
written, an unpinned key still writes.
2026-11-05 18:27:43 -05:00
unsupportedpastels 9044ee16fe refactor(gateway): remove the empty built-in hook package
gateway/builtin_hooks/ held only a docstring, and
HookRegistry._register_builtin_hooks() was an empty method called at the
start of discover_and_load(). Nothing registered through either one, so
hook loading is unchanged: user hooks under the profile's hooks/ dir
load exactly as before.

Remove both, the test helper that patched the empty method out, and
the developer docs (and zh-Hans copies) that listed the package as an
extension point.

Related: Code Health PLG-08.
2026-11-05 18:26:35 -05:00
unsupportedpastels 81ab7c7cf3 refactor(plugins): gateway hook loader imports handler.py through one shared helper
Add hermes_cli.plugin_isolation.import_user_code(path, module_name): the
spec -> sys.modules -> exec -> pop-on-failure steps for loading one file of
user code, returning None when the file has no module spec. The gateway
HOOK.yaml loader now uses it instead of its own copy of those steps.

No behavior change: hooks load exactly as before under every
plugins.isolation mode, the 'could not load handler.py' skip line and the
'Error loading hook' path are unchanged. Keeping user hooks out of the
gateway process under host isolation is a separate, opt-in change.
2026-11-05 18:25:50 -05:00
David Dudok de Wit b2ea6f6161 fix(groups): fence Stop replays and drain all work on Disband
Keep explicit Stop retries behind their original admission fence. Mint a
fresh identity when callers omit one, and drain every stoppable task on
Disband retries before allowing deletion.

(cherry picked from commit 92bed47fd5b8b6a8ce7071d67fb75515741a3139)
2026-11-05 10:09:03 -08:00
David Dudok de Wit 6d6d8f5fab fix(gateway): mark group chat sends refused before admission
Mark only refusals that prove no message was admitted.
Keep committed receipts and preparation recovery authoritative.

(cherry picked from commit 63f6f197f995cc0344c3d26d375eae1e17721cc0)
2026-11-05 10:09:03 -08:00
yoniebans 7c00c01c98 fix(gateway): bypass sessions keep the built-in side-task defaults
A --safe-mode or --ignore-user-config chat got an empty auxiliary section, so the built-in per-task defaults (vision's 120s timeout, goal_judge's 60s, voice_chat's reasoning setting) fell back to the 30s client default. Its side tasks now use the session's own saved section, which holds code defaults only, so the profile is still never read. The bypass test asserts the vision timeout inside a bypass policy_scope.

Reported-by: unsupportedpastels
2026-11-05 12:09:34 +01:00
yoniebans 6e7567be26 fix(gateway): a bypass session's turn never reloads the profile's .env
The local-turn .env reload ran for --ignore-user-config and --safe-mode sessions too, so a bypass chat read the profile file it promises not to read, and an unreadable .env could fail its turn. The reload now skips bypass sessions; the turn-runner test covers both kinds.

Reported-by: greptile-apps[bot]
2026-11-05 12:09:34 +01:00
yoniebans 8bc8980657 fix(gateway): a local turn reloads the profile's .env, as a messaging turn does
A messaging turn reloads .env before it runs (inside _current_max_iterations); a local session's turn took its saved turn budget instead and skipped that reload with it, so a key rotated in .env reached it only after a gateway restart. Side tasks now read config.yaml, whose ${VAR} references expand against the environment, so a local turn runs the same reload. Its turn budget stays the session's own.
2026-11-05 12:09:34 +01:00
yoniebansandGille 2afffc46ba fix(gateway): side tasks read the profile's config.yaml, like every other side-task reader
A local session's compression, vision and title routes came from the copy of `auxiliary:` saved when the chat was created, while background review, /btw, /review, the curator, vision's model and every `providers:` entry the routes name were read from config.yaml. A retired side-task model therefore kept failing in every older chat, and a saved key could reach an entry's new URL. Side tasks now read the profile's config.yaml in every in-process chat, as messaging chats already do. The chat's main model and route keep the saved config, --ignore-user-config / safe-mode sessions still see no profile at all, and managed workers keep their session snapshot as a whole.

This replaces the earlier rule that a changed side-task key sends the task back to the main model; the recovery test now asserts the new key reaches the next call.

The automatic-selection case is adapted from Gille's reset-compression regression tests in #147679.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
2026-11-05 12:09:34 +01:00
yoniebans 9bbe8e880f fix(gateway): a resume expands ${VAR} from the profile's current .env
The resume refresh read config.yaml with the gateway process environment, so a ${VAR} that the profile's .env sets differently produced a changed value and a needless new generation on every attach. It now reads config.yaml the way session creation does.
2026-11-05 12:09:34 +01:00
686f6c61 aab7134651 fix(gateway): keep a discovered profile's own metadata in profiles.list
The authority's roster named every profile on disk (#140417) but built those
discovered rows from a placeholder: description '', display_name set to the
handle, and neither has_avatar nor ui_meta_revisions. Desktop reads the row
for presentation — displayName() only title-cases the handle while
display_name is empty, the Edit profile dialog seeds its description field
from bot.description, and pushLocalAvatars() re-uploads a drawn image
whenever has_avatar is missing.

Send the profile's own description and display_name from its profile.yaml
(empty when it has none), and carry has_avatar and ui_meta_revisions for
every row. The two derived fields move into helpers shared with the active
profile's row so both shapes agree, and an unreadable profile.yaml still
degrades to a basic row.

(cherry picked from commit 4a0913c3fc746d5072b6abf2799ffe2a59570cfa)
(cherry picked from commit 5802ba607ab50f68cde4afe95a0a4b757fb9b0e3)
2026-11-05 02:40:06 -08:00
TekniumandGille e27c38006a fix(gateway): Bot Mode shows every bot's saved description, not just the main bot's
The shared gateway's profiles.list returned the launch profile's description but
hard-coded '' for every sibling profile it discovers. Desktop seeds Edit profile,
the avatar prompt, role search and Duplicate from that roster, so a saved
description on any non-default bot looked like it never saved (the file had it;
`hermes profile describe <bot>` read it back). Read it from the profile.yaml the
row already parses for display_name.

Reported on Discord (0.22.7, "bot descriptions are not saving"); diagnosed by
Gille in #149066, whose editor pre-read this replaces at the roster.

Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com>
2026-11-05 02:21:13 -08:00
Teknium 79f28901db docs(gateway): adapter guides and plugin allowlist name resolve_gateway_approval_card
An outside adapter following the guide kept the answer-the-oldest bug, and one using the new
helper drew a private-import warning at admission. Review follow-up.
2026-11-04 21:40:32 -08:00
Teknium 5ab58da1f6 fix(gateway): a stale approval card no longer approves the next request
Discord already binds its approval view to the tools.approval request id
(4f86e97791, 50f57a131f) and the runner passes ExecApprovalPrompt.request_id
since cfed2ca71f. Every other native-button surface still resolved by
session key alone, FIFO: when the request a card was rendered for had
already settled (timeout, typed /approve), a press on that card resolved
the NEXT pending approval in the session, one the user had not seen.

Telegram, Slack, Feishu, Matrix, WhatsApp Cloud, Teams and the relay
adapter now remember prompt.request_id with the card and resolve through
tools.approval.resolve_gateway_approval_card, which targets only that
request. A card sent without an id keeps the FIFO behaviour.
2026-11-04 21:40:32 -08:00
Teknium a3e24a4de9 fix(gateway): --cpu-profile writes the profile on gateway exit
Review finding: every gateway exit ends in os._exit via
_exit_after_graceful_shutdown, which skips the atexit writer, so
`hermes --cpu-profile p.prof gateway run` profiled the whole life and
wrote nothing. Call write_cpu_profile as a best-effort step before the
log drain (no-op when profiling is off). Net-zero lines in run.py.
2026-11-04 21:32:52 -08:00
Teknium 82a9ef60ef fix(vision): keep the sandbox exec-read at 50 MiB; only host reads get 250 MiB
Review finding (P2, _resolve_container_fallback): raising the shared ingest
cap to 250 MiB also raised the sandbox exec-read, which returns the file as
base64 on stdout and peaks near 4x the file in this process (200 MiB file ->
+800 MiB RSS). _MAX_INGEST_BYTES is back to main's 50 MiB for the sandbox and
data: paths; host-file reads (bounded during the read) use the new
_MAX_HOST_READ_BYTES = 250 MiB, which _finalize also uses as the ceiling.

Also fixes the gateway/media_fetch.py comment (P3) that claimed its 50 MB
fetch cap mirrors the ingest cap.
2026-11-04 21:29:42 -08:00
Teknium f94194ba73 feat(feishu): interactive clarify cards + native choice picker
Feishu already rendered exec approvals and update prompts as interactive
cards, but the clarify tool fell back to the base numbered-text prompt and
finite-choice slash commands (/reasoning, /fast, usage-limit switch) fell
back to text status cards. This implements the generic adapter contracts
from BasePlatformAdapter instead of a Feishu-only path:

- send_clarify(): multiple-choice questions become a card with one numbered
  button per option plus "Other (type answer)"; a tap answers through
  resolve_gateway_clarify and the callback response settles the card inline
  for every client; "Other" calls mark_awaiting_text. Open-ended questions
  use the base text prompt. Answers come from the pending clarify entry, so
  a tap after a timeout or /new reports the prompt expired.
- retire_clarify_card(): the gateway's hook for a clarify that ended without
  a tap (timeout, reset, superseding prose, typed answer) PATCHes the card
  button-less; the card is a shared card (update_multi) so the update
  reaches every recipient.
- send_choice_picker(): one-tap picker card; the tap settles the card and
  runs on_choice_selected on the adapter loop, then sends its confirmation.
- Every click passes the approval path's _validate_card_action (operator
  authorization incl. the gateway pairing check, callback-vs-card chat
  match); state pops are first-writer-wins, so a double or stale tap is
  refused.

Behaviour lives in plugins/platforms/feishu/adapter_choice_cards.py (mixin);
adapter.py stays at main's length (card-action routing became a table).
New platform.feishu.clarify/picker strings in all 17 locales, reusing each
locale's existing Telegram/Discord wording.
2026-11-04 21:29:10 -08:00
Teknium 4c466d86f2 fix(gateway): no runtime footer to a bot peer (a streamed reply sends it as its own message) 2026-11-04 21:01:10 -08:00
Teknium 17c2065b2b fix(gateway): only a real is_bot=True source is a bot peer; approval timeout reads the ctx source safely
Mock sources in the notice and approval suites carry a MagicMock is_bot; SessionSource.is_bot is a bool.
2026-11-04 21:01:10 -08:00
Teknium 0754ec0e28 fix(bot-loop): chain identity, per-send hops, fork/compression keying, one notice policy
Review round on #148903 (Greptile 4467193888..4467194077):

- Bot Mode gate (agent/bot_chain.py, tools/bot_mode_dm_chain.py) runs once per
  message_agent call before any transport, on #144789's code. Recipients are keyed
  by the author id their turns carry (bot:default for @hermes, bot:<conn>/<profile>
  for relay rows, the peer's bot:<host>/<profile> or peer:<name>/<agent>).
- Every call inside a bot-authored turn is one more hop; a person's turn sends at 1.
- A definite no-send (offline relay target, refused admission) releases its ask
  and hop; queued/ambiguous keep them.
- A reply to the turn's sender reuses #144789's notify-only path (marker +
  wake="none"); the receiver reads the marker from the text, so it survives the
  Desktop relay, which rebuilds the author. The parallel expects_reply author field
  is gone.
- Forks (_turn_origin) neither reset nor advance the chain; the chain is bound
  after compression recovery and follows a session id change (rotation carry and
  per-agent rebinding).
- gateway.bot_loop_guard.enabled: false switches the Bot Mode rules off too.
- Gateway: gateway/bot_peer_notices.automatic_notices_allowed is the one policy
  at every runtime-notice egress (platform notices incl. delegation failure,
  inactivity warning, hygiene, background failure, approval timeout, command
  acks, context-ref refusals, STT echo, status/progress lanes).
- A bot may end in silence even when it addressed us (reply_expected=True).
- reply_to_reply keys on a reply this bot DELIVERED (note_reply), not on admission.
- Tests split by owner: tests/agent/test_bot_chain.py, tests/tools/test_bot_mode_dm_chain.py,
  gateway ingress stays; config keys covered through the real config.yaml loader.
  Each finding's test fails with its guard removed (mutant run).
2026-11-04 21:01:10 -08:00
Teknium 8180676731 fix(gateway): judge a bot message once, at ingress, not again when its admission executes
The session authority re-enters _handle_message to run a committed row, so the
chain check saw its own hash and dropped every bot turn as a duplicate (found by
the live Telegram stand-in leg). Skip it under executing_admission.
2026-11-04 21:01:10 -08:00
Teknium 452d8f98bf test+docs: bot-to-bot exchanges end; bot_replies_expect_reply honoured in Bot Mode
Invariant tests (red on 71736c9f: 6 failed + chain module missing; human
controls pass) for every runtime-notice egress and both chain lanes, config
defaults for max_hops / bot_replies_expect_reply, and the user docs.
2026-11-04 21:01:10 -08:00
Teknium 4e93f80e47 fix(gateway): end bot-to-bot exchanges: no runtime notices to bots, a runtime hop cap, replies need no answer
Widens #137037 from Mattermost to the whole class (#137027):

- Runtime notices never reach a bot peer: every busy-path reply (ack, redirect,
  drain), lifecycle/status and credits notices, tool progress, interim and thinking
  bubbles, the long-running heartbeat, the auto-reset notice, and the runtime failure
  copy ("not processed", retry hints, generic failure apology) on a failed or empty
  turn. The model's own reply is still delivered.
- Chain rules in gateway.bot_loop_guard (config.yaml): a human message starts a chain,
  each bot message after it is one hop, max_hops (default 4) caps them, a bot repeating
  the same text in one chain is dropped, and with bot_replies_expect_reply false (the
  default) a bot answering the reply this bot just gave it starts no turn. A bot
  message also counts as not expecting a reply, so a silence marker ends it quietly.
- Bot authorship everywhere it is knowable: a generic per-adapter bot_users opt-in
  (config.yaml platform extra) for platforms whose events carry no bot flag,
  Mattermost props.from_bot, QQ guild author.bot.
- Bot Mode message_agent: agent/bot_chain.py owns a per-session hop count carried in
  the delivery's author envelope (runtime-written, parse_turn_author keeps it), refuses
  sends past the same max_hops and identical repeated asks, sends a reply to the bot
  that messaged this turn notify-only (expects_reply false, no wake), and refuses a
  message back to a bot whose reply said it needs no answer. tools/bot_mode_dm.py gets
  only the gate call and the wake flag (LANE dm owns that file; same wake names as #144789).
2026-11-04 21:01:10 -08:00
KoNit-K cb52533765 fix: contain Mattermost bot peer loops
(cherry picked from commit b21f2e9fdc45335c55d200c2812f895d81ec28fd, #137037;
conflicts resolved onto main: ALLOW_BOTS moved to the Mattermost gateway_env AUTHZ
entry, status suppression moved to gateway/run_turn_progress.py)
2026-11-04 21:01:10 -08:00
Teknium 787954a49f fix(gateway): session.active_list reads waiting while a viewer request holds the turn
Greptile on #148880 (Some waiting sessions stay silent): the status came from shared controls only,
so a turn blocked on a clarify batch, sudo, secret or vault prompt (session_server_requests) read
working, and the TUI never announced a background session waiting on one.
2026-11-04 20:54:25 -08:00
Teknium 57ac745bf0 fix(groups): a legacy store import never copies live router claims
Router claims are lease-like and expire in 120 s, like driver leases, which the import
already skips. Copying one only delayed the next router call.
2026-11-04 20:52:12 -08:00
Teknium 74bb0e1191 fix(groups): a Stop older than a late cap notice still retires its pause
The cap's room.activity is written from a snapshot, so a Stop can land just
before it. That Stop fences the discussion (Continue could never reopen it),
yet the pause still read as actionable on the server and in Desktop. Both
readers now treat any Stop after the discussion's own message as final.
2026-11-04 20:52:12 -08:00
Teknium 57c6a9e44b feat(groups): opt-in auto routing and Continue at the discussion cap
Gateway-hosted Group Chats gain two things:

- routing "auto" (opt-in room setting; "everyone" stays the default). The
  deterministic rules run first and never reach the router: an @mention,
  @all/@everyone, a scheduled post, a message inside an existing thread. An
  unaddressed message otherwise gets ONE tool-less auxiliary.group_router call
  over the room purpose/notes, the roster (handle, name, profile description)
  and the last 5 messages, answering strict {"members": [...]} JSON. The
  answer is written as room.routed BEFORE any turn is planned, so
  plan_next_task stays a pure function of the log; a failed or invalid answer
  records a fallback to everyone. The call runs on its own thread; its answer
  is recorded only if the re-run planner still wants exactly that route, so a
  Stop or a newer message during the call discards it.
- The cap (max_rounds / max_messages) still writes room.activity bounded, and
  groups.continue (both the standalone and the gateway route) appends
  room.continued: one more budget of rounds and messages for the same
  discussion, at most 5 times. The policy checkpoint reopens the discussion's
  projection from the room log, so Continue works after a gateway restart.

Desktop: "Assigned to X" rows (names, never ids), "Paused after N messages"
with Continue and Stop, a "Continued" row, and the auto choice in Room settings
when the gateway lists it (driver_status.routing_modes). All 9 locales.
2026-11-04 20:52:12 -08:00
Teknium 268266994b fix(terminal): draw tool images only for calls that showed one
tool_image_path ignored the tool and its outcome: it returned any
existing image named by a result field or by the args `path`/`url`/
`image_url`. So the CLI, the TUI sidecar and the canonical gateway's
local viewer drew the picture for a read_file of a PNG that failed with
"Cannot read binary file", a refused patch, a failed vision_analyze and
a write_file to x.png - each looking like the call succeeded.

It now takes the tool name and the call's error verdict: a failed call
shows nothing; args count only for vision_analyze (`image_url`);
results count for image_generate (`image`) and for any tool's
`image_path`/`screenshot_path` (browser_exec, computer_use), including
a multimodal envelope's `meta`. Every caller passes the verdict it
already has: the CLI hook's is_error, the canonical gateway's
_detect_tool_failure result, and the same detection in the TUI sidecar.
2026-11-04 13:26:30 -08:00
Adolanium d4ac2edd5d feat(tui): draw images inline in the TUI transcript
The TUI shows the same images as the CLI: a user turn's attachments under
its row, and a tool's image in its own transcript row under the trail line
that made it (anchored like inline diffs).

- tool.complete carries image_path, from both the sidecar gateway
  (tool_progress) and the shared gateway's local-viewer fields
  (local_viewer_events.complete_fields), so canonical sessions get it too.
- image.render lays an image out for the client: kitty placeholders when the
  TUI owns a terminal that paints them, half-block cells otherwise, nothing
  when display.inline_images is off or the file is not a readable image.
- <InlineImage> paints both with the generic cell painters. Renders are
  cached across virtual-scroll remounts and each kitty image goes over the
  tty once per process.
2026-11-04 13:26:30 -08:00
unsupportedpastels 0b388277f9 fix(context): read the fallback label from the resolver, and label /status and /context
The fallback marker re-derived the resolver's answer from a few local
sources, so a window a custom endpoint reported through /models (or a
local probe, provider profile, live catalog) read as "fallback" whenever
it equalled the default. Its 60s memo was also keyed without the profile
home, so one multiplexed profile's verdict labelled another's window.

get_model_context_length now records when it ended on a static default
(the generic 256K or Bedrock's 128K) per (profile home, model, base_url),
and the marker reads that record. The memo is gone.

Gateway /model drops the configured pin for a changed route before both
the number and its label, through the same helper resolve_display_context_length
uses, so a discarded 128K pin no longer shows "(pinned)" over a fallback.
/status and /context now carry the same label as /usage for resident
agents and non-resident sessions.

Addresses Greptile review on #148801.
2026-11-04 12:56:04 -05:00
Victor Kyriazakos 2111815fab feat(context): label a defaulted context window "fallback" wherever it is shown
The gateway session line printed "◆ Context: 128K tokens (detected)" for a Bedrock
model no source knew. _resolve_gateway_model_context recognised only the generic 256K
DEFAULT_FALLBACK_CONTEXT as a default, so Bedrock's static 128K was reported as
detected. The CLI surfaces showed the number with no source at all.

agent/context_pin.py already labels a model.context_length pin "(pinned)" on those
surfaces (#66168). This commit extends the same helper with a "fallback" marker:

- is_fallback_context_length(window, model=, provider=, base_url=) is True only when
  the window equals the resolver's default for that route and no local source knows
  the model. On Bedrock the default is BEDROCK_DEFAULT_CONTEXT_LENGTH, and the checks
  are the table/family step, a provider-confirmed cache row and model_overrides. The
  generic route uses DEFAULT_FALLBACK_CONTEXT, the catalog, models.dev and
  custom_providers. It does only local reads, memoised for 60s because the status bar
  repaints often. It fails open and never probes.
- Labelled surfaces: the gateway session line (via context_source "default", now
  worded "fallback, not a detected limit"), CLI and gateway /usage, the wide status
  bar (new cli.status_bar.ctx_fallback key in all 17 locales), the welcome banner,
  and the CLI and gateway /model switch summary.

No new config key. The override is unchanged: model.context_length, or
model_overrides.<provider>.<model>.context_window.
2026-11-04 12:56:04 -05:00
Bear Huddleston 862be95391 Merge pull request #148949 from NousResearch/fix/acp-service-tier-picker
fix(acp): expose session-scoped service-tier picker
2026-11-04 10:32:48 -06:00
Teknium 160339e0dd feat(bot-mode): typed request envelope, a forgotten reply relayed once, and one "never answered" sentence per dead request
- message_agent takes optional `constraints` and `expecting`; the teammate reads them as labelled
  "Constraints:" / "Reply needed:" lines under the message.
- A teammate that ends a request turn silent or empty after writing its answer elsewhere (beside a
  tool call, or in a message_agent back to the sender) has those last words relayed once, marked
  "[no final reply; their last message this turn:]". Notify-only and non-bot deliveries keep silence.
- A turn its own gateway's stop/restart interrupted is a failed receipt ("its gateway stopped or
  restarted mid-turn ... not run again"), not `cancelled`: nobody cancelled it.
- Every live-lane completion and the fan-out join lead with `outcome`: "@beta answered." or
  "@gamma never answered: <why>" plus whether resending is safe; a wait that lapses on an unreachable
  owner says so (target_state "unreachable"); a busy teammate's ack says it takes the request next.
2026-11-04 08:01:12 -08:00
Teknium 50c7d1c4b9 fix(bot-mode): a sender's cancel by delivery id always reaches the delivery's retry
message_agent's cancel picked an admission from the target's receipt file and cancelled it with
prompt.cancel. When the first attempt had just failed transiently, the owner's one automatic retry
could be scheduled but not yet recorded: the cancel hit the finished first attempt, and the retry
then ran anyway.

The cancel is now an owner RPC, bot_relay.cancel {id, profile, author_id}
(gateway/session_bot_cancel.py). It runs under the mailbox lock that admits the retry, follows the
retry when it exists, and otherwise withdraws it for good: the receipt's retry is marked refused
"cancelled", so no wake, re-delivery or owner restart admits it, and the receipt reads cancelled.
Only the delivery's own principal and author may cancel it.
2026-11-04 08:01:12 -08:00
Teknium a78d43003e fix(bot-mode): a teammate turn stopped by its sender's cancel reports cancelled, not failed
A message_agent cancel of a started request interrupts the teammate's turn (prompt.cancel on
that generation). The turn settles `interrupted` with its result, and the receipt read that as
`failed: Bot Chat delivery failed`, so the sender heard a broken delivery for a request it
withdrew. An interrupted turn with a committed result is now `cancelled` (never retried: the one
retry gate already requires outcome `failed`).
2026-11-04 08:01:12 -08:00
Teknium ef52d34bdb feat(bot-mode): message_agent fans out with one combined reply, sends notify-only, cancels its own request, and reports a blocked teammate
- target accepts up to 8 comma-separated teammates: each is admitted as before, and one
  combined runner joins their reply runners and wakes the sender ONCE with a typed outcome per
  teammate (settled/failed/cancelled/pending + reply/error/reason/target_state).
- expects_reply=false: the message is marked "[no reply needed]" after the attribution colon and
  no reply runner starts (local: the payload and intent go once the owner admitted it; relay: no
  waiter; peer: the sending runner runs unwatched), so nothing wakes the sender.
- cancel=<delivery_id>: the sender withdraws its own queued request, or stops the exact turn it
  started, through the owner's prompt.cancel; the receipt's author must be the caller.
- The owner's deliver answer carries target_state (idle/working/waiting_approval/
  waiting_clarify/paused, never stored); the ack says plainly when the teammate is parked on its
  own user, and a runner whose wait lapses pending re-reads it through a same-id replay instead
  of ending on a silent timeout.
- One reply path (#95074): message_agent refuses a DM to the bot whose delivery this turn
  answers, since its final response already goes back. Protocol v3 names fan-out and notify-only.
2026-11-04 08:01:12 -08:00
BearHuddleston 9156c854e3 fix(gateway): use followed routes for service-tier choices
Resolve a lazy session model, provider and endpoint from one effective
policy read. A following session must also ignore its retained agent
route after the profile moves; use the matching route-bound launch key
or followed config key for account eligibility without mutating the agent.

Cover both custom/OpenAI transition directions with and without a retained
agent, plus followed-account key selection. Lazy cases fail on 42500a10d9;
retained and account-key cases fail before the Sol S1 correction. All pass
with the final helper shared by the eligibility read and Fast setter.
2026-11-04 15:43:42 +00:00
BearHuddleston 42500a10d9 fix(acp): expose session-scoped service-tier picker
ACP clients advertise edit, model and reasoning controls but omit speed,
so editors cannot select the backend's existing service tiers.

Read eligible choices from the owning gateway under its profile and
credential scope, and apply picks through the validated session fast
setter without changing profile defaults. Publish tier changes to attached
viewers and read the requested next-turn value independently of active
agent metadata. Pass the reconnecting client through snapshot projection
to avoid re-entering the connection lock.

Cover route/account eligibility, session isolation, reloads, cross-viewer
updates, older owners and reconnect projection. Update ACP documentation
and regenerate the canonical gateway contract.
2026-11-04 14:53:40 +00:00
PRATHAMESH75 97d75f3eed fix(gateway): let remote Origin-less ticket dials reach the Webapp gate
The Android app dials /api/ws from a remote peer with a
`hermes-gateway-ticket.` subprotocol and no Origin header. The runtime
wrapper in gateway/run_api.py treated any Origin-less ticket dial as a
native local Desktop dial and closed it 4403 when the peer was not
loopback, so the Webapp's own ticket gate never saw the ticket its
/api/auth/ws-ticket had minted and nothing reached dashboard-auth.log.

Native grants are loopback-only, so a remote ticket can only be the
Webapp's: fall through to it, exactly as the same dial with an Origin
already does. Origin is client-controlled, so this opens no gate a
client could not already reach by adding one. Loopback dials still try
the native grant first, and a malformed ticket set is still refused.

Fixes #147361

(cherry picked from commit 86c2ae90766682fb36e27bc031bf5378e82903cb)
2026-11-03 20:11:02 -08:00
Ben Barclay 9ad4a59918 refactor(gateway): rename session_contract.Principal to SessionActor
The type is whoever acts on a canonical session: an attached CLI/TUI/Desktop
viewer, the API server, the messaging control path, cron and automation. Most
call sites already name the variable `actor`. Renaming it frees "principal"
for a member of a tenant, the meaning gateway-gateway already uses
(Tenant > Membership(Principal) > Agent), so the two repos share one vocabulary.

Mechanical: whole-word rename in every module that imports it from
gateway.session_contract. No alias is kept (internal paths are not API).
Unrelated uses of the word (Windows Task Scheduler XML) are untouched.
2026-11-03 22:58:38 -05:00
yoniebans 657e2c0e4f fix(gateway): restore a retired conversation before publishing a resolved answer
The previous fix let a discarded unknown turn resolve after idle retirement had let its conversation go, but the captured-answer branch still indexed the session before `_publish_pending` restored it. When retirement won the race between the resolution commit and its loop-side follow-up, resolving with a captured answer raised `KeyError`, skipped the completion publication and the receipt. This also fails on main before this branch. `_resolved_unknown` now brings a retired conversation back before deciding who owns the claim, so both the captured-answer and discard cases publish and return their receipt; a drain-owned resolution is unchanged.

Reported-by: helix4u
2026-11-03 22:21:37 +01:00
yoniebans 0f34c46059 fix(gateway): a resolution the drain owned keeps its media collection
When an unknown turn is resolved while the drain still holds its claim, `_resolved_unknown` leaves publication, delivery and collection to the drain. The drain queued collection only for a turn it settled itself, so if its recovery then failed (a storage error reading the result back, or ownership moving), the resolved turn's media was never queued and stayed on disk until the age-based sweep. The resolution now hands the collection to the drain explicitly, and the drain honours it on every exit.
2026-11-03 22:21:37 +01:00
yoniebans 676315b054 fix(gateway): resolving a retired conversation's unknown turn no longer crashes
Idle retirement (`session_retirement`) can let a conversation go between an unknown turn's resolution commit and its loop-side follow-up. `_resolved_unknown` looked the session up unconditionally to see whether the drain still owned the claim, so a discard without a captured answer raised `KeyError` before `_publish_pending` could restore the conversation, skipped cleanup, and left it retired. A retired conversation has no drain: treat it as not drain-owned and let `_publish_pending` restore it, as main does.
2026-11-03 22:21:37 +01:00
yoniebans f3016c2467 fix(gateway): queue admission ids, not rows, for terminal media collection
The collector only needs the admission id, but it queued whole admission rows. While a slow history scan runs, later settled turns accumulate in that queue, and an API row can carry inline images and the client's full conversation history, so those payloads stayed in memory until the earlier scans finished. Queue the id and pass it to `_release_media`.

Reported-by: greptile-apps[bot]
2026-11-03 22:21:37 +01:00
yoniebans 98f76dd52c fix(gateway): keep a cancelled drain cancelled when its delivery fails
A drain cancelled while its reply was being handed off waits for the tracked delivery task before re-raising. When that task failed, the second wait raised the delivery error in place of the cancellation, so the drain ended as a dead pump instead of a cancelled task. Log the delivery failure and re-raise the cancellation.
2026-11-03 22:21:37 +01:00