77 Commits
Author SHA1 Message Date
Teknium 6f55623cc9 feat(telemetry): count messages that arrive while a chat's agent is busy, and the day's peak of busy chats
hermes.gateway.busy_arrival.count: one row per human message that reaches a
chat whose turn is still running (the shared runtime's busy dispatch),
recorded once its fate is known: queued (ran later as its own turn),
steered (steer or interrupt-mode redirect into the running turn),
interrupted (hard interrupt, then ran next), merged (folded into another
busy message of the same burst), dropped (/stop, /new, refused, queue cap),
with the wait from arrival to the moment it reached the agent.

hermes.engagement.day.count gains gateway_peak_busy_chats_bucket: the most
chats of the owning profile with a turn running at once that UTC day, kept
in memory from the runner's running-turn set at every turn boundary
(_persist_active_agents, moved to the agent-cache mixin so the gateway.run
facade does not grow) and folded into the profile's engagement day state.

Both are opt-in through the existing shared-metrics gate; no chat ids or
session keys leave the process.
2026-11-01 13:50:05 -08:00
Teknium 00b8340b9d fix(gateway): a turn's active-session lease is released off the event loop
_release_running_agent_state released the cross-process active-session
lease inline at every turn end: registry file lock, JSON read + prune (a
liveness probe per entry) and an atomic rewrite, on the gateway loop. With
many turns finishing it was a recurring stall (active_sessions.py
_read_entries / _write_entries under release in the stall sampler).

Releases now go to one ordered worker (gateway/active_lease_io.py). Order
is kept; a release that lands after the same session's next acquire is a
no-op because the registry drops by lease id and that acquire re-keyed the
entry. The lease pinned its registry path at acquisition, so the worker
needs no profile scope. The acquire at turn start stays synchronous: it is
the cross-process exclusivity check and must complete before the turn's
first await (the in-process duplicate-turn guard depends on it).
2026-10-27 12:35:50 -07:00
Teknium 552f735368 feat(delegation): /delegation saves for the next session; --now rebuilds this session's tools
The delegate_task schema is built from the active backend and is cached prompt,
so a session-only /delegation switch changed routing but not the schema.

- Remove the session-only backend override (get/set_session_backend,
  session_delegation_config, the /new boundary clear). delegate_task routes on
  delegation.backend.
- set_global_backend(name, *, session_key='', apply_now=False): saves config;
  apply_now rebuilds that session's tools at its next turn (after the pin
  restore, so fresh agents rebuild too) and re-pins them, one opt-in cache break.
- The undeclared-parameter refusal names the fix: /delegation <b> --now or /new.
- Docs: delegation guide + provider-plugin API table.
2026-10-23 00:37:14 -07:00
Teknium 06e36326f6 fix(delegation): /new starts the chat on the saved delegation backend
A /delegation pick is session-scoped like /model, but it lives in
agent.delegation_backends keyed by session_key, and the gateway's route key
survives /new, /reset, /resume, idle/daily auto-reset and the
compression-exhausted reset. Nothing popped it at those boundaries, so the
next conversation in the chat kept overriding config.yaml's delegation.backend
(Greptile on #143126, relayed in #142563).

The conversation-boundary funnel (_clear_conversation_scope), which already
clears the /model override for every one of those boundaries, now drops the
session's backend pick too. Other chats keep theirs.
2026-10-22 14:37:10 -07:00
Teknium acb633c952 fix(gateway): release a one-turn agent once its turn ends
publish_settled_info evicted the /model --once (and /moa) agent from
inside its own turn. _evict_cached_agent pops the entry but never tears
down an agent that is still running, and nothing retried, so the agent's
clients, child agents and an interactive Codex app-server child outlived
every sweep.

The evict now asks for release_after_turn: a running agent is parked on
its turn slot, and _release_running_agent_state releases it (soft, in the
owning profile's scope) once that slot clears. It still leaves the cache
at once, so resume and Desktop never read the one-turn route off it.

Review finding by @kshitijk4poor on #140969
Refs #134889 (CLI-36)
Refs #140719
2026-10-18 03:44:27 -07:00
686f6c61 7c9b48123b fix(gateway): read the voice-channel note from the adapter that answers the source
The sidecar note asked the default profile's Discord adapter, so under multiplex_profiles a secondary profile's bot never learned it was in a voice call: the voice binding lives on the bot that joined, and the note came back empty. Resolve the adapter through _delivery_adapter_for, the same helper the voice paths in run_voice.py already use, so the note follows the bot that received the turn.

(cherry picked from commit b675bef05caef4d02f975baf9679e2fac1797ac9)
2026-10-16 19:21:04 -07:00
chelsealong 308987718b fix(gateway): reuse session-context pin on inherit_channel_pin turns too
Feed the same internal or inherit_channel_pin decision into
_pinned_session_context_prompt as _pinned_channel_inputs, so a
synthetic goal/heartbeat/resume turn (which strips message_id, a
_ephemeral_change_key input) doesn't flip the session-context block
and back on the next human turn. Also moves inherit_channel_pin to
append after reply_expected for MessageEvent positional compat.
2026-10-16 14:41:44 -07:00
chelsealong 994df5e6de fix(gateway): stop synthetic goal/heartbeat turns from stomping channel_pin
Goal continuation, heartbeat due-prompts and /goal resume events are built
non-internal (they must still pass authorization) with channel_prompt=None.
_pinned_channel_inputs() only reused the pin for internal events, so these
synthetic turns recorded (None, parent_chat_id) as the new channel_pin,
dropping the channel prompt and channel_overrides for that turn and flipping
them back on the next human turn (#125763/#124179-style A->B->A: shorter
ephemeral prompt, changed _agent_config_signature, cached-agent rebuild,
prefix-cache miss).

Add MessageEvent.inherit_channel_pin, independent of `internal` (which also
gates authorization bypass and touch_activity), and set it on the three
producers that carry no channel inputs of their own. _pinned_channel_inputs
now reuses the pin for internal OR pin-inheriting events.

Fixes #126109
2026-10-16 14:41:44 -07:00
John Paul Soliva 9e48e33348 fix(gateway): a wake after an agent eviction reuses the session-context pin
/stop, /undo, /model and a config model change evict the cached agent and
clear the in-memory session-context pin, but keep the channel pin.
_rehydrate_prompt_pins only restored the durable snapshot when BOTH pins
were missing, so the first internal event after such an eviction (kanban
wake, delegation completion, watch notification) found no pin to reuse
and rendered the context from its origin-only source: no chat name, no
user name. The next human turn re-keyed back. The ephemeral system prompt
changed twice (A->B->A), each change rebuilding the cached agent and
re-prefilling the whole conversation.

Rehydrate now runs whenever the context pin is missing, and keeps a live
channel pin. Human turns never rehydrate, so an evicted agent's next human
turn still re-renders from its real source.
2026-10-16 14:41:44 -07:00
Teknium 65bec86e37 Merge origin/main (#27: 19 commits to 517b5e10fe) into feat/unified-gateway-runtime
45 conflicts, all from #135177 (lefthook + `ruff check --fix` over 2,366 files). No other main commit
touches a conflicted file. Resolution: the branch side of each file, then main's exact autofix
(ruff 0.15.10, `--config ./ruff.strict.toml --fix`) on it — rewrites only (typing generics, noqa
drops, import forms), no import removed. tests/cron/test_bot_chat_cli_home.py stays deleted
(1cc9cb9199 retired its lane).
2026-10-08 12:09:24 -07:00
Teknium 65f03b0c19 fix(gateway): make routing-entry yolo tri-state so untoggled entries follow the launch policy
90219bdb40e5 restores the persisted routing value, OFF included, before the frozen `--yolo` launch
policy can seed. That fixed a revived revocation. But `SessionEntry.yolo` defaulted to False, and
every entry nobody toggled stores False: branch, reset, legacy migration, cron, new sessions,
every pre-upgrade row, and the /new boundary funnel (which wrote False). All of those now read as
"owner turned it off". So a `--yolo` launch was lost after an owner restart, on /branch, and after
/new. That breaks `apply_launch_yolo`'s "seeded once per session boundary" contract. It fails safe
(approvals are asked again), but it is a behaviour change.

`SessionEntry.yolo` is now Optional[bool]:
- True/False = an explicit /yolo toggle. An explicit OFF still beats a `--yolo` launch across
  restarts, so an owner /yolo OFF is never revived.
- None = nobody toggled it, so the launch policy applies. `restore_gateway_yolo(key, None,
  launch=...)` seeds it through `apply_launch_yolo` (once per boundary).
- The boundary funnel resets the toggle to None instead of False.
- `create_local_session` no longer copies the launch policy into the toggle.

Wire format: `yolo_toggle` carries the tri-state. `yolo` keeps its old meaning (True only when
ON), so older readers see the same value. Rows without `yolo_toggle` (written before this change)
map ON to True and False to None. False was the default for "never toggled" there, and it followed
the launch policy before 90219bdb40e5.

Tests (tests/gateway/test_yolo_launch_tristate.py, dA2's probes):
- An untoggled entry keeps launch yolo in a fresh interpreter, while an explicit OFF stays off.
- Launch yolo is re-seeded after a real `_clear_session_boundary_security_state`, but not after a
  plain owner OFF.
Both fail on r5-picked. His test_yolo_restart_revocation, test_yolo_command, tools/test_approval_yolo,
managed-worker flags and busy-controls tests stay green.

Follow-up to 90219bdb40e5 (#135061)

Review: https://github.com/NousResearch/hermes-agent/pull/135061
2026-10-08 12:07:00 -07:00
Maciej Lisiewski d76cce4f11 ci: apply all automatic linter fixes available 2026-10-08 17:22:54 +02:00
Teknium 0a25fab6cf Merge origin/main (#23: 34 commits to dde8800ed9) into feat/unified-gateway-runtime
Conflicts:
- gateway/run_turn.py: the branch moved the block to run_turn_prepare; main's b1a39a1b1b
  restore_session_yolo call ported there.
- Desktop pool (main 167e9fdb84 no-idle-reap): the branch removed the local backend pool;
  pool-retire.{ts,test.ts} and pool-limits-setting.tsx stay deleted, main.ts keeps no pool.
  main's MSIX relaunch-waiter logging (bdb8f92f3b) auto-merged.
- e2e/core/no-idle-reap.spec.ts rewritten for the single local gateway: a profile dial stays on
  the same gateway pids and descriptor past the old pool idle window (1 passed, 3.6m).

One /yolo contract: gateway-owned sessions (session_busy_controls.yolo_config -> toggle_session_yolo)
and managed workers (restore_session_yolo) use main's tools/approval_yolo.py; the launch --yolo seed
(apply_launch_yolo) moved there and is skipped under a process-wide --yolo.
2026-10-08 02:00:21 -07:00
Teknium b1a39a1b1b refactor(approval): one session /yolo contract for CLI, TUI/Desktop and the messaging gateway
The persist / restore / carry logic for session YOLO had grown three copies
(the CLI mixin, tui_gateway, the gateway runner), each with its own key choice,
ordering and error handling. tools/approval_yolo.py now owns it:

- toggle_session_yolo: flip or set, persist BEFORE the live flip (a racing
  restore never revives an OFF), stored-copy-aware so a toggle after a restart
  turns it OFF; a failed write logs a warning and never blocks the flip.
- restore_session_yolo: re-arm a persisted flag in a fresh process; skipped
  under the frozen process --yolo.
- transfer_session_yolo: move the live flag to a continuation id.
- with_session_yolo: a row being created records a live flag (lazy first row,
  compression child, /branch).

Each surface keeps only its storage: the session row for CLI/TUI/Desktop, the
routing entry for the gateway. Removed: HermesCLI._transfer_session_yolo /
_persist_session_yolo, GatewayRunner._restore_session_yolo, the TUI-only
restore helper, and the TUI's post-publish set_session_yolo carry (the
compression publish now writes the child row with the flag).

Closes a gap the unification exposed: a CLI manual /compress that rotated the
session dropped YOLO (the next dangerous command prompted, and a --resume of
the continuation came back with it off).
2026-10-08 00:49:32 -07:00
Teknium b09c202d9e Merge origin/main (#21: 191 commits to e675225af2) into feat/unified-gateway-runtime
Brings #134209 onboarding (setup chat, /initiate-setup, setup_choose/start_chat cards) and
889eddcd86 (/yolo survives a gateway restart).

Conflicts (6):
- gateway/run_turn.py, tui_gateway/server.py: the branch moved these blocks into siblings;
  main's edits ported there (run_turn_prepare: first-contact `message`/setup_command, the
  _restore_session_yolo call after _hmwa_open_session; agent_factory: coding_selection
  session-platform toolsets, setup_choose/start_chat lifecycle tools; session_registry:
  _lazy_resume_info default route).
- tests/gateway/test_yolo_command.py: both tests kept.
- Desktop composer submit / slash / wiring: main's parked-card answering and submitWhileBusy
  with the branch's busy-input policy in its steer branch; slash `hidden` folds into the
  prepared-submission journal; wiring keeps main's onboarding chain without the removed pool.

Semantic fixes:
- gateway/session_busy_controls.yolo_config writes main's persisted SessionEntry.yolo before
  the live flag, so an owner `/yolo` OFF is not revived by _restore_session_yolo next turn
  (new test test_owner_yolo_off_is_not_revived_by_the_persisted_restore).
- wiring runKickoffSlash awaits the branch's Promise<boolean> slash dispatcher.
- de/es/fr/ja/zh-hant: translate the branch's composer keys main's overlay-completeness test
  now requires.
2026-10-07 17:09:40 -07:00
Teknium 889eddcd86 fix(gateway): /yolo survives a gateway restart
/yolo on a messaging platform only flipped tools.approval's in-memory
set, so any gateway restart (hermes update, crash, systemd restart,
Desktop reopen) silently put the chat back on manual approvals while
the user still believed YOLO was on.

The flag now lives on the chat's routing entry (SessionEntry.yolo,
persisted in gateway_routing like the /model override). /yolo writes it
before flipping the live flag, materializing the entry for a
first-message toggle; every turn re-arms the live flag from the entry it
already resolved; switch_session carries it across compression-tip and
binding repoints; the conversation-boundary security funnel (/new,
/resume, /branch, auto-reset) clears the persisted copy with the
in-memory one so a restart can never revive a bypass the user dropped.

Also corrects the security docs, which still claimed /yolo sets
HERMES_YOLO_MODE.
2026-10-07 16:15:35 -07:00
Teknium 1e500ebce2 Merge origin/main (#16: 4,465 commits to 9dcab1e440) into feat/unified-gateway-runtime
99 conflicted files resolved in nine area lanes (Desktop composer, Desktop renderer/shared
client, Electron main, Ink, tui_gateway, gateway, state, CLI/config, cron/tools/CI). The
unified runtime's architecture wins where it deliberately replaced a mechanism (pool,
fallback lanes, claim poller, renderer queue); every orthogonal main change is kept, ported
into the <stem>_<topic> sibling that now owns relocated code.

Notable ports and repairs in this merge:
- tui_gateway/server.py relocated regions: atomic _attach_built_agent (#49852), slash ranking
  registry names, minted-key helpers methods_session.py calls, request_overrides, service tier.
- gateway/run_turn*.py: i18n accessors for the removed reply constants, first-contact
  internal/DM rule, rotated-compression persist, fast-mode tiers, prompt-pin rehydration.
- webhook: route_from_chat_id decodes main's opaque webhook:v2 chat ids (old ids still read).
- ws.py: main's per-connection dispatch queue with authority-first dispatch inside it.
- state: schema event for runtime revision renumbered to 31; worker mirrors updated for
  main's message uid/merge fields, on_contended lease arg, compression columns.
- config: branch's bot_chat_delivery_timeout removal renumbered v47 -> v50.
- Electron: primary-profile pin, redirect header scoping, remote fetchJsonForBackend, SSH
  teardown on profile delete, held-update dial gate restored (lost in earlier merges).
- Desktop: canonical clarify.request mapped to main's questions[] card; replay truncation vs
  snapshot_required; periodic secondary prune tick restored; DELETE already_absent restored.
- cron/tools: diagnostic category on cron failure notices restored; owner_holds_delivery
  ported onto the authority; admitted receipts can no longer be cancelled locally.
2026-10-06 14:24:24 -07:00
0dbaf33f67 fix(gateway): measure fallback drift from the session's winning model on its primary route
The channel-override baseline from #131295 skipped the session /model tier and
normalized the expected model with the agent's current provider. Both disagree with
how the agent was built, which turned three cases main handled into wrong verdicts:

- a recorded Nous switch off a session /model choice was evicted (the baseline was
  the channel model, so the (alias, backing) record never matched);
- a session-primary fallback that landed on the channel's model was kept;
- an OpenRouter primary that fell back to native Anthropic was kept, because the
  channel slug normalized under the fallback provider equals the fallback model.

Resolve the baseline in the same order as _resolve_session_agent_runtime (session
/model, channel_overrides, global) and normalize it for the agent's primary provider,
the same route the welcome-host pin already uses. A session override whose provider
was unavailable this turn ran on the default route (the resolver drops it for that
turn and retries next turn), so it is the baseline only when its provider is the
agent's primary provider.

A model-name comparison cannot see a fallback that serves the configured model on
another endpoint; with a channel model that differs from the global one, #131295
turned that case from evicted into kept. A turn whose agent has
_provider_fallback_active (set only by _try_activate_fallback, cleared by
restore_primary_runtime) now evicts before any name comparison.

The baseline lives in GatewayAgentCacheMixin._fallback_baseline_model beside
_is_intentional_model_switch, which keeps gateway/run_turn.py under its code-health
line cap (the channel lookup from #131295 had grown it past main).

Session tier taken from #132238; both regressions diagnosed in review by @andrexibiza.

Co-authored-by: salch-cred <141555468+salch-cred@users.noreply.github.com>
Co-authored-by: andrexibiza <84248988+andrexibiza@users.noreply.github.com>
2026-10-06 11:24:29 -04:00
Teknium 3a6d709921 fix(plugins): agent builds wait out a slow provider import; only per-turn callers are bounded
The 10 s concurrent-load bound also hit slow but healthy first imports. A
second agent built while a provider's import took longer than 10 s got no
memory provider for its whole session, and with checkpoint_required every
compaction then failed closed.

The bound now applies only inside plugin_loader.bounded_load_wait(), which
the per-turn gateway memory identity signature uses. Other callers (agent
builds) wait for the in-flight load. Cycle detection still breaks a
cross-thread deadlock for every caller.
2026-10-05 15:55:58 -07:00
John Paul Soliva 38151eed64 fix(discord): the triggering-message note no longer depends on message_id presence
With the discord toolset loaded, a typed turn rendered the per-turn
triggering-message line and keyed the agent cache on message_id presence,
while slash, /thread and voice turns (no triggering message) did not, so
the pinned session-context prompt re-rendered on every switch between
them. The line is now static and the presence bit is out of the key; the
real id still rides the user message when there is one.

Reported by teknium1 in the #132758 review; fix shape from andrexibiza's #132758.

Co-authored-by: Andrex Ibiza, MBA <andrexibiza@gmail.com>
(cherry picked from commit 2a3f04a67d1aa6dc43628ce0959ddb699baa84f8)
2026-10-05 15:15:44 -04:00
teknium1 7e53b3ef82 chore(memory): remove bundled honcho provider; install from the plugin catalog 2026-10-02 04:39:28 -07:00
kshitijk4poor e162360c96 refactor(gateway): move the prompt-pin store API into a session sibling
gateway/session.py is the SessionStore facade assembled from session_*
mixins; new behaviour belongs in a topical sibling, not the facade.
PROMPT_PIN_VERSION, sanitize_prompt_pin and get/set_prompt_pin move
verbatim to gateway/session_prompt_pin.py (SessionPromptPinMixin).
SessionEntry keeps only the field and its (de)serialization; the agent
cache imports the schema from the defining module. No behaviour change.
2026-09-28 15:54:07 +05:30
kshitijk4poor 9dc52c21e4 fix(gateway): rehydrate prompt pins off the event loop
_rehydrate_prompt_pins read the store synchronously from inside the
prepare-turn coroutine. get_prompt_pin takes SessionStore._lock, which
worker threads hold across routing-index DB writes, so the first
internal event after a restart (startup resume, kanban wake) could stall
the loop behind another session's save.

Rehydrate is now async, reads through async_session_store, and runs in
_hmwa_prepare_turn before the sync context-pin helper, which goes back
to its main-branch signature. _persist_prompt_pins hands the raw
snapshot to set_prompt_pin, the one validator.
2026-09-28 15:54:07 +05:30
kshitijk4poor f660ba2289 fix(gateway): durable prompt pins never outlive their privacy policy
The persisted pin carried rendered session-context bytes but not the
privacy.redact_pii they were rendered under. After a restart, the first
internal event restored and reused them verbatim, so bytes rendered with
redaction off reached the model after redaction was switched on. Base
re-rendered under the current setting after a restart.

The pin now records redact_pii (the in-memory ephemeral_pin becomes
(change_key, text, redact_pii); the durable schema requires the flag).
Bytes from a different policy are never reused, including by internal
events in the same process, which had the same bypass until the next
human turn.

Simplified on the way:
- sanitize_prompt_pin is the one validator; the snapshot builder and
  rehydrate no longer re-check the shape by hand, and use
  PROMPT_PIN_VERSION instead of a literal.
- Rehydrate runs once, from the context path, and only when this process
  holds no pins for the key (a restart). Eviction clears only the context
  pin, so an evicted agent still re-renders instead of reviving the
  snapshot. The channel path no longer rehydrates or takes a session id.
- _persist_prompt_pins drops its synchronous get_prompt_pin pre-check,
  which took the store lock on the event loop; set_prompt_pin already
  no-ops an unchanged snapshot. Its call-count test goes with it.
- set_prompt_pin builds its candidate with dataclasses.replace like
  set_model_override, and no longer accepts None (no caller cleared).
- The read-fence test folds into the restart/reset test.
2026-09-28 15:54:07 +05:30
JoaoMarcos44 3275dcead3 fix(gateway): fence prompt pin rehydration
(cherry picked from commit d15d25312dfaf62add09fe36d2203be5288d28fe)
2026-09-28 15:54:07 +05:30
JoaoMarcos44 0ba9e1b7f0 fix(gateway): persist internal prompt pins
(cherry picked from commit 0da37221523633bef2c3454ece033f8eac285893)
2026-09-28 15:54:07 +05:30
teknium1 c6dc7ab120 Merge remote-tracking branch 'origin/main' into feat/unified-gateway-runtime
Merge #8: 2,771 commits from main (4b05f764fa). Ports main's moved code into the
unified runtime (run.py/run_turn.py splits, tui_gateway server, backup/restore
split), keeps the detached-daemon Desktop lifecycle, and adapts main's new tests
to the owner/authority contracts (canonical session deletes, retired-id
tombstones, zero-writer browsing, hermes_yaml, platforms() markers).
2026-09-27 02:30:04 -07:00
kshitijk4poor e3ffee06f2 refactor(gateway): give _pinned_channel_inputs explicit inputs
The two pin helpers encode one rule (a human turn records, an internal
turn reuses) but took it in two shapes: _pinned_session_context_prompt
gets `internal` as a keyword while _pinned_channel_inputs took the whole
event and read getattr(event, ...) itself. Match the sibling:
(session_key, channel_prompt, source, *, internal), with the call sites
passing the event's values. MessageEvent.channel_prompt/internal and
SessionSource.parent_chat_id are declared dataclass fields, so the
getattr guards (including the new context-prompt call site) go too.
2026-09-27 00:42:55 +05:30
kshitijk4poor 9c13cb21d4 fix(gateway): restore pinned parent_chat_id via replace_source
_pinned_channel_inputs copied the internal-turn source with a plain
dataclasses.replace, which drops the wire-invisible provenance
(transport ref, authorization home, identity). Under multiplexing the
copied source then reaches _run_agent / _delivery_adapter_for without
its identity and falls back to heuristic profile/adapter routing.
gateway/AGENTS.md requires source copies to go through
session_identity.replace_source; use it (late import, matching the other
gateway callers) and drop the now-unused dataclasses import.
2026-09-27 00:42:55 +05:30
kshitijk4poor 7820464695 fix(gateway): internal-only sessions still pin the session-context prompt
The internal-event branch returned an unpinned render when no pin existed
yet, so sessions that only ever see internal turns (kanban/API-server
wakes, startup resumes) re-rendered the session-context prompt every turn
and lost the verbatim-reuse immunity base gave them. Reuse an existing pin
when present; otherwise fall through to render-and-pin as before.

Applies teknium1's inline review suggestion on #122048.
2026-09-27 00:42:55 +05:30
Kyzcreig 9c18b383de fix(gateway): internal events must not re-key the session-context / channel prompt pin
Internal events (kanban wakes, delegation completions, watch
notifications) carry a source rebuilt from the persisted origin: no
chat_name/user_name/message_id/parent_chat_id, and channel_prompt=None.
_pinned_session_context_prompt re-rendered from that source, so every
internal turn re-keyed the pin and the next human turn re-keyed it back
(A->B->A). Each flip rewrote already-sent system bytes and collapsed the
prompt cache to the static prefix. The same toggle happened through
channel_prompt and parent-keyed channel_overrides in the ephemeral
system prompt.

- _pinned_session_context_prompt(internal=True) reuses the existing pin
  verbatim and never re-pins.
- Human turns record (channel_prompt, parent_chat_id) in
  ConversationState.channel_pin; internal turns reuse them (main run and
  the queued follow-up path).

tests/gateway/test_internal_event_pin_wiring.py drives the real
_handle_message_with_agent human->internal->human with _run_agent
stubbed. Both tests fail on main and pass here; dropping internal= at
the call site makes both fail again.

(cherry picked from commit 3b38527ae7f0f498ca265fe9f64fcbc1c8c7765c)
2026-09-27 00:42:55 +05:30
teknium1 b0aa41586e Merge commit '09fbc27a4a93' into fix/106742-thread-resolution
# Conflicts:
#	apps/desktop/electron/backend-dial-claim.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/pool-limits.test.ts
#	apps/desktop/electron/pool-spawn-coordinator.test.ts
#	apps/desktop/electron/quit-guard.test.ts
#	apps/desktop/src/app/chat/composer/controls.test.tsx
#	apps/desktop/src/components/notifications.test.tsx
#	apps/desktop/src/plugins/hermes-bots/group-chat-view.inline-code.test.tsx
#	apps/desktop/src/sdk/profile-routing.test.ts
#	apps/desktop/src/store/gateway-spawn-priority.test.ts
#	apps/desktop/src/store/profile.test.ts
#	gateway/run.py
#	gateway/run_turn.py
#	gateway/status.py
#	hermes_cli/dashboard_auth/ws_tickets.py
#	hermes_cli/web_routers/sessions.py
#	tests/agent/test_secret_scope_tier1_migration.py
#	tests/cron/test_bot_chat_delivery_child_env.py
#	tests/cron/test_bot_chat_warning_replay.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_empty_payload.py
#	tests/cron/test_cron_mcp_toolset_empty_block.py
#	tests/cron/test_cron_memory_contract.py
#	tests/cron/test_cron_no_agent.py
#	tests/cron/test_cron_request_overrides.py
#	tests/cron/test_cron_workdir.py
#	tests/cron/test_run_one_job.py
#	tests/cron/test_scheduler.py
#	tests/cron/test_sessiondb_init_hang.py
#	tests/desktop/test_bots_chat_live_append.py
#	tests/gateway/test_api_server_runs_extraction.py
#	tests/gateway/test_approval_prompt_redaction.py
#	tests/gateway/test_auto_reset_clean_context.py
#	tests/gateway/test_auto_reset_evicts_cached_agent.py
#	tests/gateway/test_compression_deferred_soft_result.py
#	tests/gateway/test_compression_session_id_persistence.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_fallback_chain_reload.py
#	tests/gateway/test_model_switch_after_auto_reset.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_warning_wiring_conservation.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_chat_q_exit_clear.py
#	tests/hermes_cli/test_cli_preloaded_skills.py
#	tests/hermes_cli/test_cron.py
#	tests/hermes_cli/test_ensure_gateway_service.py
#	tests/hermes_cli/test_kanban_gateway_restart_handoff.py
#	tests/hermes_cli/test_observational_sessiondb_modes.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_sessions_export_md_cli.py
#	tests/hermes_cli/test_single_query_session_finalize.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_state/test_hermes_state.py
#	tests/tools/test_bot_live_owner_delivery.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_bot_retry_policy.py
#	tests/tools/test_bot_turn_lock.py
#	tests/tui_gateway/test_bot_live_owner_delivery.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tui_gateway/server.py
#	ui-tui/src/__tests__/createSlashHandler.test.ts
#	ui-tui/src/__tests__/submissionCore.test.ts
#	ui-tui/src/app/useSessionLifecycle.ts
2026-09-23 07:47:09 -07:00
teknium1 07646a7f72 fix: a chat switched to Codex no longer sends its model to the previous provider's endpoint
The messaging gateway's /model wrote only model + provider to the session row. A row the
Desktop/TUI had persisted with the Nous Portal route kept that base_url and api_mode, so resuming
a chat switched to openai-codex built provider=openai-codex on the Portal URL and posted
gpt-6-luna-900k to inference-api.nousresearch.com/v1/chat/completions: "Model 'gpt-6-luna-900k'
isn't available on ChatGPT or Codex Subscription".

- SessionDB.update_session_model writes the whole route (provider, base_url, api_mode) in both
  shapes resume reads (top-level for TUI/Desktop, gateway_runtime for the CLI) whenever a
  provider is given; the gateway /model passes the switch result's endpoint.
- Resume readers (TUI/Desktop, CLI, gateway rehydrate) drop a persisted base_url that is another
  built-in provider's canonical endpoint, so rows already written by older builds heal.
- A gateway session override whose credentials failed to re-resolve is resolved for its own
  provider on the turn instead of being layered over the default provider's runtime (which
  produced openai-codex + Nous key + Nous URL). If it still cannot resolve, that turn runs on the
  whole default route with the existing one-shot "Provider fallback" notice; the override is kept
  and retried next turn.
2026-09-23 04:44:31 -07:00
teknium1 eec5bd6673 Merge remote-tracking branch 'origin/main' into fix/106742-thread-resolution
4,048 upstream commits (Sep 16-23) composed onto the unified gateway runtime by six
per-domain conflict lanes (gateway, desktop/shared, cli/state, cron, bots/tui_gateway, ink).
Retired lanes stay retired (Desktop secondary pool + exit supervisor, deferred Bot Chat
pending queue, subprocess retry adoption); main's gateway.standalone, host-role claim,
clarify capability gate, config-read caches and idle gates ride on top.
2026-09-23 03:25:31 -07:00
kshitijk4poor e1c71bd4fc fix(gateway): legacy checkpoints: true signature carries the default limits, one cfg_get per key
#119406 made absent keys carry DEFAULT_CONFIG's value in the cache signature, but the
legacy bool branch still emitted None for max_snapshots/max_total_size_mb/max_file_size_mb
while `_checkpoint_agent_kwargs` builds that agent from DEFAULT_CONFIG — so migrating to
`checkpoints: {enabled: true}` rebuilt the cached agent for nothing. The dict/absent branches
were also re-implementing cfg_get's own semantics; one `cfg_get(cfg, section, key, default=…)`
covers absent section, non-dict section, absent key and explicit null. DEFAULT_CONFIG is
imported at module top (hermes_cli.config already is; gateway/restart.py sets the precedent).
2026-09-23 00:25:29 +05:30
kshitijk4poor 8eeec5423b fix(gateway): threshold_tokens: null rebuilds a cached agent instead of waiting for a restart
The agent-cache signature reads the raw config file (no DEFAULT_CONFIG merge) and mapped
an absent key to None. Since #115986 shipped compression.threshold_tokens=256K, the
documented opt-out (`hermes config set compression.threshold_tokens null`) produced the
same None as "never set", so the signature did not change and live gateway sessions
kept compacting at 256K until the process restarted (community report on #115986).

Absent keys now carry DEFAULT_CONFIG's value — what the agent was actually built with —
so absent == explicit default (no spurious rebuild) and absent != explicit null.
2026-09-23 00:10:27 +05:30
teknium1 e03af55edd chore: merge origin/main (resolve website/docs/integrations/providers.md) 2026-09-19 10:47:48 -07:00
teknium1 9bd796c4fb refactor(gateway): run_agent_cache resolves adapters through the intake/delivery seams
Mechanical migration of the `_adapter_for_source` call sites in this sibling. Intake policy sites take `_intake_adapter_for`; every send/edit/typing/pending-slot site takes `_delivery_adapter_for`. Part of #88715 (phase 4).
2026-09-19 01:28:23 -07:00
teknium1 c7b82ed488 fix(gateway): heal a persisted opencode relay URL on /model override rehydration; share the per-model api_mode lookup
- gateway/run_agent_cache.py::_rehydrate_session_model_override re-resolved api_mode from the
  target model but kept the persisted base_url verbatim, so a /v1-stripped or other-family
  relay URL written by an older build stayed on a chat_completions model. Run
  normalize_opencode_base_url after re-resolution for opencode-family providers (test drives
  the production rehydration entry: red before, green after).
- hermes_cli/model_switch.py::_build_switch_result now uses model_derived_api_mode() instead
  of its own exact-key table lookup, so a custom provider extending a family slug
  (opencode-go-bridge) gets the same per-model wire on live /model as on resume.
- tui_gateway/server.py::_resolve_agent_model_runtime: the re-derivation guard is
  any(overrides.values()) — a model_override dict always carries the three keys, so
  'if overrides' was always true.

Part of #96066
2026-09-19 01:16:39 -07:00
teknium1 b71359066c fix: /stop halts background subagents and returns their partial results as interrupted completions
A turn's hard interrupt fans out only to `_active_children`; background
delegate_task units are detached from the parent at dispatch
(`_dispatch_background` / honor_parent_interrupt=False), so /stop left them
running to completion and their result arrived minutes later as a parked
wake. Every stop surface now also calls
`tools.async_delegation.interrupt_for_session` for the session's units:

- gateway: `_interrupt_and_clear_session` (busy /stop, /new share it — /new
  already did this in `_handle_reset_command`, the second request is
  idempotent) and the idle `_handle_stop_command` tail, which replied
  "No active task to stop." while a background child was running; it now
  stops them and replies "Stopped".
- tui_gateway `session.interrupt` (Desktop Stop / TUI stop) — own UI sid +
  spawner id only, so a viewer tab never kills gateway work.
- acp_adapter `cancel`.
- CLI `/stop` already used `interrupt_all` (process-wide); unchanged.

The stop recurses: depth>0 delegations are always synchronous
(`_model_background_value`), so the child's hard interrupt reaches its
workers through its own `_active_children` fan-out, and each level's
interrupted partial result rolls up as that child's completion.

An interrupted child's entry now carries what it actually had: the loop's
`final_response` is the "Operation interrupted." placeholder (also appended
as the closing assistant row), so `_build_result_entry` takes the child's
last real assistant text as `summary` and keeps the placeholder as `error`.
The unit finalizes normally and re-enters at once as its completion notice
(status=interrupted, "Partial output: ...", "Subagent Task Interrupted" on
TUI/Desktop) instead of the chat waiting for the child's budget to run out.

Docs: delegate_task description, tools/AGENTS.md, delegation.md,
gateway-session-lifecycle.md.

Part of #114456
2026-09-18 20:34:42 -07:00
Yagna Vudathuandjoaomarcos 87cd8a3c84 fix(gateway): /stop, /new and /reset keep a parked internal wake instead of discarding it
_interrupt_and_clear_session popped the adapter's single pending slot and dropped whatever it
held ("consume and discard", 59575d6a91). That was right when the slot only ever carried the
user's stale follow-up text, but internal wakes (async-delegation completion notices,
kanban/cron notify+wake) now park in the same slot and are claim-settled the moment the adapter
admits them, so the pop lost them for good: the drain that runs after the command found an
empty slot and the session idled until the next user message (#114456, ~6 min stall in the
reported session).

Now the human follow-up is still discarded, but an internal wake stays parked (promoted out of
the overflow FIFO when a discarded human head occupied the slot) and the post-command drain
starts it immediately. Applies to every caller of the helper — /stop (busy fast path, handler,
pending sentinel, thread sibling), /new and /reset — since a wake that arrives a second after
/new runs against the fresh session anyway; whether a completion pinned to the closed session
may run stays with _resolve_async_delegation_session (fail-closed).

Salvaged from #114538 (@whyyagswhy, earliest filer): the pop-and-re-park mechanism is theirs;
widened here to the /new and /reset callers and the overflow promotion, and the invariant tests
rewritten against the real adapter drain. #114540 (@JoaoMarcos44) reached the same fix
independently; its stop-reason taxonomy and overflow analysis informed the class coverage.

Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
2026-09-18 09:27:02 -07:00
2f569002f5 fix(gateway): async completion cannot re-pin a route after /stop or /new won the race
The non-compression branch of _resolve_async_delegation_session awaited the
spawning-session row lookup and then unconditionally called switch_session(),
so a run invalidated (/stop) or a route replaced (/new, /resume) while the
lookup was pending was overwritten by the stale completion's pin.

- Snapshot the routing key's run generation before the await and re-check it
  before mutating the route; invalidated -> drop the injection, route untouched.
- switch_session(expected_session_id=...) turns the /resume primitive into a
  CAS for this caller, mirroring advance_compression_session: a route that
  moved past the snapshot wins over the late completion.
- _current_session_run_generation extracted from _is_session_run_current.

Slimmer redo of #113692 (@KoNit-K) and #113716 (@wangtaotaotao95): same
direction, without the second routing-authority lock and authorize callbacks.

Fixes #113690

Co-authored-by: KoNit-K <konit.block@protonmail.com>
Co-authored-by: wangtaotaotao95 <wangtaotaotao95@users.noreply.github.com>
2026-09-18 09:20:39 -07:00
Austin Pickettandxxxigm 1792e8bf5f fix(local-runtime): resumed llamacpp sessions follow the live managed port on every surface (#114336)
* test(local-runtime): pin resume to the live managed llama.cpp port

A session that stored last boot's loopback URL must not keep the client
on a dead ephemeral port after the supervisor moves.

* fix(local-runtime): follow the live managed llama.cpp port on resume

Sessions persist last boot's loopback URL, so a supervisor port change
left the client on a dead endpoint. Drop that snapshot for llamacpp
and keep the live supervisor URL.

* fix(tui_gateway): drop the llamacpp snapshot URL at one seam

_resolve_agent_model_runtime already discards a persisted base_url when the
resolution came from the local runtime; the second blank in
_stored_session_runtime_overrides (wrapped in a try/except around an import
and a string compare) duplicated it.

* fix(cli): keep a launch-time --base-url on a same-provider llamacpp resume

Re-resolving the managed endpoint is for the snapshot URL a session persisted;
an explicit --base-url for the provider the session already ran on is user
intent and stays in charge. Also hand target_model to the resolver like the
provider-changed branch does.

* fix(gateway): rehydrated llamacpp overrides follow the live managed port

Same bug class as the CLI and TUI resume paths: after a gateway restart the
persisted /model override kept last boot's loopback URL over the freshly
resolved managed endpoint, so a supervisor that came back on an ephemeral port
(18434 busy) left the session on connection errors.

* docs(local-runtime): port-fallback warning no longer asks for a model re-pick

---------

Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
2026-09-17 14:24:53 -04:00
teknium1 931b5ff9e7 fix(opencode): every credential-resolution surface keys off the model it will send; family heal only for built-in providers
Closes the remaining atoms of #112600.

A) The CLI startup path already passes `-m` as target_model (c358a6fba0), but
   its siblings still resolved credentials against config's `default`: the CLI
   auth-fallback rung, `--resume` credential re-resolution, the gateway
   provider-override helper (channel overrides, persisted /model switches,
   API-server provider refresh), the gateway fallback chain, the TUI /model
   switch-from runtime and ACP agent construction. With a `*-free` default the
   OpenCode free-tier rung fired first and a Go-only model was built against
   the keyless Zen relay ("Model mimo-v2.5 is not supported"). Each now passes
   the effective model; `_resolve_runtime_agent_kwargs_for_provider` grows an
   optional `target_model` and the two test stubs of it accept the kwarg.

B) normalize_opencode_base_url rewrote the /zen vs /zen/go segment for ANY
   provider matched by opencode_provider_family, including custom providers
   merely named after a family (`opencode-go-bridge`, #85589) whose relay the
   user declared explicitly in `providers:`. The family heal now applies to the
   built-in canonical providers only; custom prefix-named providers keep their
   per-model api_mode routing and /v1 handling. Documented in the providers
   guide.

C) Same function: the official-host check uses parsed.hostname (a port no
   longer defeats the heal) and only the path is edited, so query/fragment
   round-trip instead of being dropped.

Fixes #112600
2026-09-17 08:59:22 -07:00
teknium1 485d5f6848 Merge remote-tracking branch 'origin/main' into fix/106742-thread-resolution
# Conflicts:
#	gateway/run_turn_runner.py
2026-09-16 17:57:23 -07:00
teknium1 9d0ab880bc fix: attribute gateway lifecycle stops to their system issuer, not the user
The parent commit only stamped a tool_reason on the watchdog producers, so a
reason-less hard_cancel still minted "explicit stop requested" (a USER reason)
for gateway stop/restart, session eviction while a turn runs, abandoned SSE
runs and TUI/desk shutdown - the exact confusion class of #112647, failing open
toward "user". Pass a system tool_reason at those producers (run_shutdown,
run_agent_cache via the eviction caller only - /stop and /new stay user-owned -
and api_server) so only genuine surface stops book interrupted_by_user.

InterruptScope.cancel now takes tool_reason so the Hermes Console user-cancel
branch is attributed to the user; the console timeout keeps the host slug.

Also repairs the red CI: the cross-process lease tests stub agent.interrupt
with (message, hard_cancel) only, so the new tool_reason kwarg raised TypeError
inside the lease's fallback and the stub never fired. Widen the doubles, and
update the two shutdown mocks that asserted the exact interrupt() call.
2026-09-16 17:48:17 -07:00
teknium1 43c477571e Merge origin/main (61154b6f68) into feat/unified-gateway-runtime
1,446 upstream commits; 84 conflicted files resolved per family, porting
every post-branch main fix onto the cutover architecture rather than
dropping it or resurrecting replaced pre-cutover code:

- gateway/cron/kanban/a2a: #111988 hygiene bound, silence-marker rejection,
  UX copy, clarify-card retirement on explicit no-answer, Slack task-card
  publication_suppressed, webhook per-route coalescing (wrapping the durable
  admit_producer), kanban dead-worker last output + fingerprint boot witness,
  migrate-profile-identity control verb, SupervisedTickerThread, api-server
  terminal_run_status; config migration 45 kept from main, ours renumbered 46.
- hermes_cli/hermes_state/tools/tui_gateway: read_only SessionDB alongside
  explicit db_path, sessions_cmd observational flag, #111958 served-profile
  skips service install, #111999 usage source upsert repair, archived Bot Chat
  title release, find_canonical_owner, #111151 serves_routed_profile MCP scope,
  session_search after/before/exclude, server.py hunks into session_registry/
  agent_factory siblings, #111775 runtime_check resolve hook, Bot Chat
  silence rule on delivery reply.
- Desktop/apps/shared: pool modules stay deleted; SSH teardown fencing,
  default-profile + peer-window routing, per-session remote headers,
  server->client requests, generated gateway contract with our owner stamps
  and session.replay_gap retained.
- Ink: canonical WebSocket client kept; storedSid reconnect recovery, server
  request store, generated contract types, plain-language copy ported.
2026-09-16 13:20:14 -07:00
NanPan 213dc82a3a fix(gateway): preserve profile context for interrupt reapers
(cherry picked from commit fbbc7af05c23ecde8801130390b2303489386b37)
2026-09-16 11:06:06 -07:00
Erosika f28fff4cf7 fix(gateway): the boundary evict commits for a context engine without a provider
`commit_memory_session` also calls the context engine's `on_session_end`,
and that engine runs without an external memory provider. The boundary
evict gated on `_memory_manager` alone, so a context-engine-only agent lost
its reset and compress notification. Resource evicts keep the provider-only
gate: the session may resume, so the engine's session stays open.
2026-09-16 09:16:20 -07:00
erosika 6babbba26f fix(gateway): reset and compress commit memory before evicting the agent
/new and /compress evicted the cached agent through the resource-only soft
release, so memory providers never received on_session_end for the
conversation that just ended; main's CLI ran commit_session_boundary_async
at the same point. Route the two boundary mutations through the cache
sweep's commit-then-release path. /model and rewind keep the plain evict.
2026-09-16 09:16:17 -07:00