Commit graph

14289 commits

Author SHA1 Message Date
Brooklyn Nicholson
16aa09aca5 feat(mcp): first-class MCP tab — catalog, GUI auth/probe/logs, per-tool gating
A Cursor-style MCP manager inside Capabilities, plus the backend it needs.

- Server list with brand/favicon avatars + live status dot and a capability
  summary (N tools, M prompts, K resources); Servers | Catalog views.
- Catalog: one-click install of Nous-approved servers with required-env prompts.
- GUI OAuth: Authenticate opens the system browser from the TTY-less backend and
  verifies a token actually lands; header/API-key servers are never pushed down
  OAuth; a dirty mcp.json can't drop a freshly-persisted auth field.
- Full-width mcp.json editor (ecosystem document format) + pinned stdio/agent
  LogTail; probes cached 5m and keyed by (profile, config) so revisiting never
  respawns the fleet or shows a stale probe.
- Whole-map persistence (PUT /api/mcp/servers) so deletes/toggles actually stick
  (the generic /api/config deep-merge could not remove keys).
- perf: MCP probe/auth no longer hold the global skills lock, so a slow stdio
  spawn can't stall every other request into a 15s timeout.
- per-tool include/exclude gating (lib/mcp-tool-filter) mirroring the CLI loader.
2026-07-03 05:08:28 -05:00
Brooklyn Nicholson
7e6d60aadc feat(desktop): unify Skills/Tools/MCP into the Capabilities page
Merge the old Skills + Toolsets tabs and pull MCP out of Settings into one
master-detail "Capabilities" hub (Skills / Tools / MCP / Browse Hub).

- Skills: usage-sorted from real per-skill activity, provenance badges
  (learned / built-in / hub), edit + archive for learned skills, per-tab bulk
  toggle; full-bleed empty states.
- Tools: usage-aware, container-queried rows (no early two-column collapse).
- Settings panels move onto the shared config-record query cache; the deleted
  Settings MCP page redirects (/settings?tab=mcp → /skills?tab=mcp).
- Lazy, profile-scoped, TTL-cached usage analytics so the heavy 365-day scan
  never blocks the Skills/MCP tabs. Full en/zh strings (ja/zh-hant inherit).
2026-07-03 05:08:21 -05:00
Brooklyn Nicholson
e0325cf769 feat(desktop): Capabilities foundation — shared utils, master-detail, editors, primitives
The reusable base the Capabilities rework sits on:
- lib/{text,time,format,json-format}: consolidate ~30 hand-rolled string/date/
  number/JSON helpers behind one set of tested utilities.
- master-detail scaffold (MasterDetail / ListColumn / DetailColumn / DetailPane /
  CapRow / ListStrip / ToolChip / ICON_BUTTON) + row-hover; tabs-as-data
  PageSearchShell; EmptyState + ErrorBanner; a shared LogTail terminal surface.
- framed CodeEditor + JsonDocumentEditor, wired into every in-app markdown/JSON
  edit surface (profile SOUL.md, memory nodes, right-click sidebar profile).
- shared React Query cache helper (writeCache) + per-profile-switch/ debounce hooks.
2026-07-03 05:08:13 -05:00
brooklyn!
662426ec3d
Merge pull request #57636 from NousResearch/bb/desktop-messaging-poll
fix(desktop): poll messaging sessions so platform traffic appears live
2026-07-03 05:04:58 -05:00
Brooklyn Nicholson
c1e825399c test(gateway): stub get_compression_tip in stale-guard db mock
The routing-heal added to get_or_create_session calls
SessionDB.get_compression_tip; the stale-guard suite's bare MagicMock db
returned a Mock the heal then assigned as session_id, failing JSON
serialization. Model the real contract (a non-compressed session's tip is
itself) so the heal is a correct no-op.
2026-07-03 04:46:01 -05:00
Brooklyn Nicholson
dfb28cc631 refactor(desktop): only poll the transcript when it's a messaging session
The active-transcript poll armed a 5 s timer for every selected session
and no-op'd inside the tick for local chats (already live over the
websocket). Derive activeIsMessaging and gate the effect on it so local
chats never spin an idle timer.
2026-07-03 04:38:45 -05:00
Brooklyn Nicholson
52d0d671e7 fix(desktop): poll messaging sessions so platform traffic appears live
Inbound Telegram/WeChat/Discord messages are written by the background
gateway, not the desktop websocket that drives local chats. Without
explicit polling the messaging sidebar and the open transcript stay
frozen until the user manually refreshes.

Desktop:
- MESSAGING_POLL_INTERVAL_MS (10 s): interval poll of the messaging
  session list so new platform sessions surface automatically.
- ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS (5 s): poll the currently-
  viewed messaging transcript and re-hydrate the chat state when the
  FNV-1a signature changes (hash covers role + timestamp + content).
- sameCronSignature now compares lineage_root_id / source / profile /
  preview / message_count / last_active / ended_at so stale previews
  and activity times are no longer silently ignored.
- sessionMatchesStoredId helper de-dups the id / _lineage_root_id check.
- refreshMessagingSessions exposed from useSessionListActions so the
  controller can use it in the poll effect.

Gateway:
- SessionStore._compression_tip_for_session_id: look up the latest
  compression continuation for a session id.
- SessionStore._heal_compression_tip_locked: rewrite a stale entry to
  the compression child before returning it, so a restart or failed send
  no longer leaves the store pinned to the compressed parent.

Co-authored-by: lawyer112 <lawyer112@users.noreply.github.com>
2026-07-03 04:29:22 -05:00
Teknium
1c4cc00f73
fix(moa): user_turn fanout — synthetic advisory marker must not count as a user turn (#57598)
The advisory view appends a synthetic user marker when it ends on an
assistant turn (Anthropic end-on-user rule) — i.e. on every tool iteration
after the first. The user_turn prefix hash treated that marker as the last
user message, so the hashed prefix included the grown mid-turn context and
the signature changed every iteration: advisors re-ran per iteration,
silently defeating the once-per-turn cadence (live smoke test: 2 fan-outs
for a 2-iteration task; expected 1). Hoist the marker to a module constant
and skip it when locating the last REAL user message. Verified: iteration-2
signature now equals iteration-1 (cache HIT); a new real user message still
re-triggers the fan-out.
2026-07-03 01:24:58 -07:00
teknium1
eb99f82ce4 fix(browser): surface launch diagnostics when debug browser never opens the CDP port
Follow-up to the salvaged early-exit retry fix (#35617): the debug-browser
launch path was fire-and-forget (stderr to DEVNULL, no logging), so every
platform failure — Windows singleton forward to an existing instance, bad
profile dir, missing shared libraries, policy blocks — collapsed into the
same unactionable 'port 9222 isn't responding yet' message and debug
reports contained nothing.

- launch_chrome_debug() returns a structured ChromeDebugLaunch with
  per-candidate attempts (state, exit code, stderr tail)
- browser stderr is captured to <hermes_home>/chrome-debug/launch-stderr.log
- clean exit (code 0) without the port opening is detected as Chromium's
  single-instance forward and produces a targeted user hint to close all
  running instances of that browser
- crash exits surface the stderr tail (e.g. missing libnspr4.so)
- every spawn/exit is logged to agent.log so hermes debug share captures it
- CLI (/browser connect) and TUI/desktop (browser.manage) both print the hint
2026-07-03 01:05:22 -07:00
LeonSGP43
c74f093523 fix(browser): retry next candidate when debug launch exits early 2026-07-03 01:05:22 -07:00
Teknium
c7103c637c
feat(desktop): CLI/dashboard parity — skills hub, MCP test/toggle/catalog, maintenance ops, log filters (#57441)
* feat(desktop): CLI/dashboard parity — skills hub browser, MCP test/toggle/catalog, maintenance ops, log filters

Brings desktop GUI to parity with hermes skills/mcp/doctor/backup/debug-share/
curator/memory CLI commands and the dashboard's System + Skills-hub pages:

- Skills page: new Browse Hub tab (search official/GitHub/community sources,
  preview SKILL.md, security scan verdicts, install/update with live action log)
- MCP settings: connection test (tool listing), per-server enable/disable
  toggle, and a Catalog tab installing Nous-approved MCP servers with env prompts
- Command Center: new Maintenance section (doctor, security audit, backup,
  debug share links, curator status/pause/run, memory file status + reset)
- Command Center system logs: file (agent/errors/gateway/desktop), level, and
  substring filters instead of a fixed agent.log tail
- hermes.ts API client + types for all the above; en/zh locale strings (ja and
  zh-hant inherit via defineLocale)

* feat(desktop): backend model catalogs in toolset config — hermes tools parity

Completes the `hermes tools` parity gap: after picking an image/video
generation backend the CLI runs a model picker (e.g. FAL's multi-model
catalog with speed/strengths/price); the desktop toolset drawer now has the
same flow as a radio-card list.

- web_server: GET /api/tools/toolsets/{name}/models (catalog + current +
  default for the active or named provider row) and PUT .../model
  (validated write to image_gen.model / video_gen.model), reusing the CLI's
  plugin catalog helpers so GUI and `hermes tools` stay in lockstep
- desktop: ModelCatalogPicker in ToolsetConfigPanel — per-model cards with
  speed/strengths/price, in-use + default badges, disabled until the
  backend is the active one; provider selection now mirrors is_active
  locally so the catalog unlocks without a refetch
- tests: 3 backend endpoint tests (catalog shape invariants, persist +
  validation), 2 component tests, 2 API-contract tests; en/zh strings
2026-07-03 01:02:47 -07:00
Teknium
9e044cf795
feat(moa): per-preset fanout cadence — user_turn runs advisors once per user turn (#57591)
New preset key 'fanout': 'per_iteration' (default, unchanged behavior)
re-runs the reference fan-out whenever the advisory view changes — every
tool iteration. 'user_turn' runs the advisors ONCE per user turn and lets
the aggregator act alone for the rest of the tool loop — the original MoA
shape (upfront multi-model synthesis, then a single acting model), and the
obvious lever on MoA's wall/cost multiplier (advisor generation dominates
per-turn latency).

Implementation reuses the existing turn-scoped reference cache: in
user_turn mode the cache signature hashes only the prefix up to the LAST
user message, so mid-turn advisory-view growth doesn't change the key and
iteration 2+ is a cache HIT (advice reused, zero advisor spend, no
re-trace). A new user message changes the prefix and re-triggers the
fan-out. Unknown fanout values normalize to per_iteration.
2026-07-03 01:02:44 -07:00
Teknium
6eb39c2bbe
fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing (#57585)
OpenCode Go serves minimax/qwen via Anthropic Messages (base URL without
/v1 — the SDK appends /v1/messages) and glm/kimi/deepseek/mimo via OpenAI
chat completions (base URL WITH /v1). The runtime stripped /v1 for
anthropic-routed models, and the TUI/desktop + gateway persisted that
stripped URL to model.base_url. Every later chat_completions model then
POSTed to https://opencode.ai/zen/go/chat/completions — a 404 (the
marketing site). Result: only minimax worked; glm/deepseek/kimi all 404ed.

- New normalize_opencode_base_url(): symmetric /v1 normalization —
  strip for anthropic_messages, re-append for chat_completions /
  codex_responses on opencode.ai hosts (heals persisted stripped URLs;
  custom proxy overrides untouched)
- Applied at all three former one-way strip sites (resolve_runtime_provider
  x2, switch_model)
- opencode_model_api_mode: all Qwen models on Go AND Zen now route via
  /v1/messages per current published endpoint tables (previously only
  qwen3.7-max on Go — qwen3.6-plus etc. would 404 the same way)
- Catalog refresh: Go gains deepseek-v4-pro/flash, glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus; Zen gains glm-5.2,
  kimi-k2.7-code, minimax-m3, qwen3.7-plus

Reported by IndieSuperhuman on X: opencode-go 404s for any model other
than minimax.
2026-07-03 00:46:45 -07:00
Teknium
372f8195c7
fix(moa): default temperatures to unset — provider default, like single-model agents (#57440)
A single-model Hermes agent never sends temperature; the provider default
applies. MoA hardcoded reference_temperature=0.6 / aggregator_temperature=0.4,
and the coercion float(preset.get(key, 0.6) or 0.6) made unset IMPOSSIBLE to
express: absent, null, empty, and even an explicit 0 all collapsed to the
baked-in default. Every MoA advisor and aggregator therefore ran at 0.6/0.4
while the same model running solo used the provider default — silently
skewing solo-vs-MoA comparisons and overriding provider-tuned defaults.

- moa_config normalization: temperatures coerce to None when absent/blank/
  invalid (new _coerce_float_or_none); explicit values incl. 0 honored.
- moa_loop: _preset_temperature() resolves preset values; None flows to
  call_llm, which already omits the parameter when None (same contract as
  max_tokens). Aggregator still inherits the acting agent's own configured
  temperature when the preset doesn't pin one.
- conversation_loop (context-mode MoA): same resolution, no more hardcoded
  0.6/0.4 at the call site.
- DEFAULT_CONFIG preset + web_server payload models + docs updated: unset
  is the default, pinning stays available.
2026-07-03 00:22:49 -07:00
kshitijk4poor
e1a1dac848 fix(agent): enforce marker-strip invariant with a single terminal sweep (#57491)
Follow-up to the per-site strips from the review gate. The two copy-site
strips are correct but positional — a copy site added after the assembly
loops would re-leak _db_persisted into the child-session flush. Add a single
terminal sweep (_strip_persistence_markers) run once on the fully-assembled
compressed list so the invariant 'no compacted message leaves compress()
carrying a persistence marker' is structural, not dependent on copy-site order.

- agent/context_compressor.py: _strip_persistence_markers() called before
  compress() returns; helper docstring notes the sweep is the authoritative guard
- tests/agent/test_context_compressor.py: structural regression — neuter the
  per-site helper to a leaking copy, assert the terminal sweep still strips
- tests/run_agent/test_compression_persistence.py: pin the fixture assumption
  behind the exact-equality row-count assertion
2026-07-03 12:51:12 +05:30
nankingjing
3e204bd771 fix(agent): strip _db_persisted when assembling rotation compression transcript (#57491)
Shallow messages[i].copy() during context compression propagated the
_db_persisted marker from cached gateway incremental flushes into the
post-rotation compressed list. _flush_messages_to_session_db then skipped
every row when writing to the new child session, so gateway restarts
lost the compacted transcript (severe amnesia).

Strip the marker in _fresh_compaction_message_copy() and add regression
tests for rotation flush + compressor assembly.

Fixes #57491
2026-07-03 12:51:12 +05:30
kshitijk4poor
5e2b051e60 test(slack): give the MPIM reaction-guard test real teeth
The reaction-guard regression test defined a local _should_react lambda and
asserted it against itself — a tautology that would stay green even if the
production guard at _handle_slack_message reverted to (is_dm or is_mentioned),
re-introducing the unmentioned-MPIM reaction spam this PR fixes.

Replace it with a shared _reaction_guard helper plus a source-introspection
test that pins the production expression: asserts (is_one_to_one_dm or
is_mentioned) is present and (is_dm or is_mentioned) is absent. Mutation-checked
— reverting the adapter guard now fails the test.

Follow-up self-review finding on the salvage of #57339.
2026-07-03 12:34:53 +05:30
Victor Kyriazakos
accd672054 fix(slack): MPIMs (group DMs) obey shared-surface mention gating + reaction guard
Group DMs (MPIMs) were classified as DMs and thereby exempted from every
operator control that shared surfaces are supposed to honor: allowed_channels,
require_mention, strict_mention, free_response_channels, and the reaction
guard. Symptom: the bot added 👀/ to unmentioned MPIM
messages and still invoked the agent (which then returned NO_REPLY) instead of
the gateway dropping the event before model execution. Removing an MPIM from
allowed_channels did not disable it.

Root cause is the DM classification at adapter.py:
    is_dm = channel_type in {"im", "mpim"}
used for BOTH routing exemptions and reaction gating. An MPIM is a shared
surface (multiple humans can see and trigger the bot), not a private 1:1 DM,
so it must be gated like a channel.

This behavior was introduced/reinforced by a trail of Slack group-DM PRs:
- #4633  fix(slack): treat group DMs (mpim) like DMs + reaction guard
- #54632 fix(slack): subscribe to message.mpim + mpim scopes so group DMs work
- #54663 fix(slack): group DMs work OOTB + reinstall nudge
#54632/#54663 correctly made MPIM messages *reachable*; #4633 over-reached by
giving them the DM mention/reaction *exemptions*. This corrects only that
over-reach.

Fix (minimal): introduce `is_one_to_one_dm = channel_type == "im"` and key the
two EXEMPTION sites off it instead of `is_dm`:
- mention/allowlist gating block (`if not is_one_to_one_dm and bot_uid:`)
- reaction guard (`(is_one_to_one_dm or is_mentioned)`)
`is_dm` is intentionally retained for session/thread scoping and chat_type
labeling, where treating an MPIM as a persistent multi-party conversation is
correct — only the mention/reaction exemptions were wrong.

Docs: slack.md now distinguishes 1:1 DMs (mention-exempt) from group DMs
(shared surface; obey require_mention/strict_mention/allowed_channels/
free_response_channels; reactions only when @mentioned).

Tests: +7 in test_slack_mention.py (MPIM unmentioned dropped under
require_mention and strict_mention; MPIM mentioned processed; MPIM off
allowed_channels dropped; MPIM in free_response opted in; 1:1 IM still exempt;
reaction guard drops unmentioned MPIM). Updated _would_process to model the
is_one_to_one_dm gating + strict_mention. 72 passed.
2026-07-03 12:34:53 +05:30
Brooklyn Nicholson
42bc07d107 fix(desktop): cancel downloads triggered by link-title fetch window
The hidden BrowserWindow used by fetchLinkTitle to scrape page titles
had no will-download handler on its session.  When a link artifact URL
responds with Content-Disposition: attachment, Electron fires will-download
and the file is saved for real — explaining the spurious download on the
Artifacts page.

Add guardLinkTitleSession() (parallel to the existing audio-mute guard for
#49505) that installs a will-download handler which immediately cancels
every download item on the hermes:link-titles session.  Call it from
getLinkTitleSession() right after the request-type blocklist is wired up.
2026-07-03 01:46:27 -05:00
Brooklyn Nicholson
7e9e13fe55 fix(desktop): use symbol-namespace codicon for Model settings nav 2026-07-03 01:37:41 -05:00
Brooklyn Nicholson
88b720ebb4 fix(desktop): use gift codicon for update-available toast
Add optional notification icon override and use codicon-gift on the
update-ready toast so it reads as a present rather than generic info.
2026-07-03 00:56:16 -05:00
Gille
551e5af50d
fix(config): preserve owner on atomic writes (#56644) 2026-07-03 14:27:34 +10:00
Gille
e9ce250374
fix(file-tools): preserve container paths for docker file ops (#56637) 2026-07-03 14:18:20 +10:00
Brooklyn Nicholson
3e21cfdebb fix(desktop): clear stale active todos on turn end AND on rehydration
A turn that ends without a final `todo` update left the composer "Tasks N/M"
panel pinned with its last item stuck pending/in_progress, and it survived
restarts because the panel is read back from stored session history.

Two coupled fixes (the first alone is undone by the second path):

- Turn end: clear a still-active todo list on `message.complete` and on a
  terminal `error` (new `clearActiveSessionTodos` — active lists only; a
  finished list keeps its short linger so the last checkmark still lands).
- Rehydration: `hydrateFromStoredSession` runs *after* a turn completes, so an
  "active" stored list is stale, not in-flight. It now restores only a
  *finished* list (via new `todosForHydration`) and drops anything still
  active — otherwise it re-pinned the panel right after the turn-end clear and
  resurrected it on every restart.

Salvages #52996 (@0disoft): the fix shape (clearActiveSessionTodos on turn
completion, preserving the finished-list linger) is carried forward and ported
onto the current use-message-stream/ folder split (gateway-event.ts), then
extended to the rehydration path per review.

Co-authored-by: 0disoft <rodisoft1@gmail.com>
2026-07-02 21:22:45 -05:00
Brooklyn Nicholson
e40175f069 fix(desktop): stop macOS Tahoe misplacing the traffic lights
On macOS Tahoe (Darwin 25+), a nonzero titleBarOverlay height makes
setWindowButtonPosition() miscalculate the native traffic-light position
(electron#49183), shoving the lights into the left titlebar tools. Pass
height 0 there so the lights land at the configured inset; the renderer
paints its own drag strips, so nothing is lost. Pre-Tahoe is unchanged.

Gate on the truthful Darwin kernel major (25 = Tahoe) rather than the
product version, which macOS reports as 16 or 26 depending on build SDK.
2026-07-02 21:14:28 -05:00
Brooklyn Nicholson
66c3d595d1 feat(desktop): cap overlay inner-page width at 75rem
Add a shared PAGE_MAX_W (1200px) and center OverlayMain within its pane
so settings and command center bodies stay readable instead of sprawling
on wide/ultrawide displays.
2026-07-02 20:40:57 -05:00
Brooklyn Nicholson
89acc19606 fix(dump): flag API keys visible only to the shell, not the managed backend
hermes debug share reads os.getenv — the invoking terminal's environment — but
launchd/systemd and the desktop-spawned `serve` backend load credentials from
~/.hermes/.env, not the login shell. A key exported in the shell but absent
from .env is invisible to the backend, yet the dump printed a bare "set",
sending support down a phantom "the key is configured" path.

This was the actual trap behind a "Desktop has no web_search / no tools"
report: FIRECRAWL_API_KEY was a shell export (so `debug share` in a terminal
read "firecrawl set") but not in .env, so the launchd backend's
check_web_api_key returned False and web_search was gated off — which a
contributor then misdiagnosed as a missing `desktop` platform registration.

The dump now annotates any key set in-process but missing from ~/.hermes/.env
with "(shell only — not in .env; managed/desktop backend may not see it)" so
the mismatch is obvious instead of hidden behind "set".
2026-07-02 19:52:18 -05:00
Teknium
64ed99a6e6
fix(webhook): close per-delivery session at the true end of the run (#57423)
The merged webhook session-close fix (#57370, salvaging #57322) wrapped
handle_message in a try/finally — but BasePlatformAdapter.handle_message
is fire-and-forget: it spawns _process_message_background and returns
before the agent run starts. The finally-close therefore ran BEFORE
get_or_create_session created the session row, found no session_id, and
silently no-op'd — the ghost-session leak persisted on the real path.
(The shipped test masked this by stubbing handle_message with a fake
that created the row synchronously.)

Move the close to an on_processing_complete override — the lifecycle
hook the base class fires at the TRUE end of the run, on the success,
failure, and cancellation paths alike. Empirically verified through the
real fire-and-forget pipeline: before, ended_at stayed NULL; after,
ended_at is set with end_reason=webhook_complete and the row is
prunable.

Tests now stub only the runner-side _message_handler (the seam the live
gateway injects) so handle_message / _process_message_background /
on_processing_complete all run for real; adds an AsyncSessionDB-facade
coverage test for the coroutine-await branch.
2026-07-02 17:39:09 -07:00
Brooklyn Nicholson
4aad27b751 fix(desktop): extend startup long-timeout to the whole boot data burst
Broadens Tranquil-Flow's profile-startup timeout fix (#48518) from getProfiles
+ refreshActiveProfile to the rest of the calls the desktop fires during
connect: /api/config, /api/config/defaults, /api/model/info, /api/model/options,
/api/cron/jobs. On a profile-heavy or remote install any of these can exceed
the 15s DEFAULT_FETCH_TIMEOUT_MS while the backend is alive-but-busy (e.g.
list_profiles walks the skill tree per profile), surfacing as the spurious
"Timed out connecting to Hermes backend after 15000ms" that hangs the UI
(#48504).

Uses the surgical per-call mechanism (renamed STARTUP_PROFILE_REQUEST_TIMEOUT_MS
→ STARTUP_REQUEST_TIMEOUT_MS) rather than raising the global default (the
alternative in #48526): the liveness poll /api/status and all interactive/
runtime calls keep the short default, so a genuinely-dead backend is still
detected fast and the boot readiness probe (waitForHermes) is untouched.

Supersedes #48518 (carried as the base commit) and #48526 (global-default
raise). Fixes #48504.

Co-authored-by: YapBi <129007007+HeLLGURD@users.noreply.github.com>
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>
2026-07-02 19:36:43 -05:00
Tranquil-Flow
584d3ae532 fix(desktop): extend profile startup REST timeouts (#48504) 2026-07-02 19:36:43 -05:00
Brooklyn Nicholson
4d88facfc1 fix(desktop): let settings content use full pane width
Remove the max-w-4xl wrapper from SettingsContent so every settings
page can use the available overlay width.
2026-07-02 19:31:57 -05:00
kshitijk4poor
ed4123792c refactor(providers): dedupe extra_headers normalizer + key picker groups by headers
Follow-up to @helix4u's #57336 salvage. Two review findings:

- W1: model-picker grouped custom-provider rows by
  (api_url, credential, api_mode) but NOT extra_headers. Entries sharing a
  URL+credential+api_mode yet declaring different headers (e.g. per-tenant
  routing behind one proxy) collapsed into one row and probed /models with
  whichever header set was seen first (order-dependent). Fold a canonical
  header identity into group_key so distinct header-authed endpoints stay
  separate; drops the now-dead first-non-empty merge branch.
- W2: the extra_headers stringify+None-filter comprehension existed in 5
  copies (config.py x2, runtime_provider.py, model_switch.py, models.py).
  Extract one shared hermes_cli.config.normalize_extra_headers primitive;
  all sites now call it.

Tests: +normalize_extra_headers unit tests, +regression test proving two
same-endpoint entries with different headers stay distinct and each probes
with its own headers. 223 targeted tests pass; ruff clean.
2026-07-03 04:23:15 +05:30
helix4u
ab40e952f3 fix(providers): pass extra headers to model discovery 2026-07-03 04:23:15 +05:30
kshitij
80a774f972
Merge pull request #57379 from kshitijk4poor/salvage/vllm-local-context 2026-07-03 03:58:27 +05:30
kshitijk4poor
0950dae2fa Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	scripts/release.py
2026-07-03 03:52:15 +05:30
kshitijk4poor
201b646d67 fix(gateway): complete on_session_end coverage across all eviction paths
Follow-up to the cherry-picked #31856 fix. The contributor's guard defers
idle-TTL eviction until the session store reports the session expired, so the
expiry watcher can tear the agent down and fire MemoryProvider.on_session_end()
with the live transcript. Two gaps remained:

1. Memory-leak regression for mode='none' sessions. _is_session_expired()
   returns False forever for the 'none' reset policy, so the naive guard would
   never idle-evict those agents — reopening the unbounded-cache leak the idle
   sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable()
   (a public predicate: will the expiry watcher EVER finalize this session?) and
   gate the deferral on it. mode='none' agents fall through to soft eviction as
   before.

2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths
   (_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via
   _release_evicted_agent_soft, which by design does NOT fire on_session_end.
   If cache pressure evicts a finalizable-but-not-yet-expired agent before it
   expires, the watcher later finds no cached agent and the hook is skipped.
   Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is
   finalizable and not yet expired, commit end-of-session extraction via the
   live agent's own (fully-scoped) memory manager using commit_memory_session()
   — extraction WITHOUT provider teardown, so the eviction stays soft and a
   resumed turn keeps working. Skipped for mode='none' (no missed boundary to
   compensate) and expired sessions (the watcher tears those down directly).

This closes #11205 for ALL eviction paths and reset policies, not just the
idle-sweep + finite-policy case, while preserving the soft-eviction
resumability contract (never calls close() on a live session).

Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap
commits for finalizable / skips for none, real is_session_finalizable
predicate); all mutation-checked. Contributor's original 2 tests updated to
assert the finalizable path explicitly.
2026-07-03 03:46:43 +05:30
Hermes Trismegistus
90b618f48a fix(gateway): keep idle cached agents alive until session actually expires
The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents
as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the
session hadn't expired yet. In daily-reset mode the reset can fire
hours after the last user message — evicting the agent early means
the session-expiry watcher has no agent in cache to call
on_session_end() with, so memory providers miss the live transcript.

Now the sweep checks the session store before evicting: if the
session still exists and hasn't expired, the agent stays in cache
so the expiry watcher can tear it down properly later.
When the session store is unavailable or throws, falls back to the
original eviction behavior (safe default).

Fixes: #11205
2026-07-03 03:46:43 +05:30
kshitij
46ada7ed42
Merge pull request #57377 from kshitijk4poor/chore/author-map-31856
chore: add trismegistus-wanderer to AUTHOR_MAP for PR #31856 salvage
2026-07-03 03:38:40 +05:30
kshitijk4poor
1c93799b49 fix(agent): self-review follow-ups on vLLM local-context salvage
Self-review (ruff+ty lint diff = 0 net-new; 2-agent deep review) surfaced one
Warning + comment-accuracy nits; no Critical:

- W1: the local-probe TTL cache memoized None (probe failure) for 30s, so a
  probe that failed during a startup race would suppress a legit retry once
  the server came up. Cache only positive results — still fully bounds the
  hot-path probe rate (reachable servers cache their value) while an
  unreachable one re-probes on the next call. Add a regression test asserting
  a None result is NOT cached (retry re-probes); mutation-verified.

- Tighten the platform-guard comment: gateway/TUI/cron already construct with
  quiet_mode=True (gated by `not agent.quiet_mode`), so the guard's active job
  is CLI dedup vs show_banner, not "filling the gateway/TUI gap" as originally
  worded.

Verified not-issues (per review): positive-value 30s cache does not break the
reconcile-after-restart freshness contract (restart = fresh process, empty
cache); cache key is collision-safe; platform guard is correct in both
directions (no runtime path leaves platform None on a non-CLI surface).

Tests: 149 passed. ruff clean; ty 0 net-new vs base.
2026-07-03 03:36:22 +05:30
kshitijk4poor
e73adb5043 fix(dashboard): disable ws keepalive ping on loopback to survive event-loop stalls
Desktop/dashboard WebSocket connections drop during long agent operations
(delegate_task subagents, large model outputs) when the uvicorn event loop is
GIL-starved for minutes. Root cause: uvicorn's ws keepalive ping runs on the
SAME event loop as agent turns. A single synchronous GIL-holding call on a
worker thread (a regex/scrub over a large output, or a long subagent turn)
freezes the loop, so it cannot process the incoming pong within ws_ping_timeout
and uvicorn closes an otherwise-healthy connection (#53773: 'event loop stalled
226.3s'; #48445/#50005). Loosening the timeout only raises the threshold — a
multi-minute stall sails past any finite window.

The keepalive ping exists to detect half-open connections (reverse-proxy 524,
dropped tunnels), which cannot happen on loopback: there is no network or proxy
in the path, and a dead local client tears the socket down with a real FIN/RST
that starlette surfaces as WebSocketDisconnect regardless of the ping. So on
loopback the ping provides ~no liveness value while actively killing
recoverable stalls — disable it entirely (ws_ping_interval/timeout=None).

Non-loopback (public) binds sit behind a Cloudflare Tunnel where half-open IS a
real failure mode, so the ping stays at 20/20 to detect it.

Empirically verified (real uvicorn + websockets peer): with ws_ping=None the
server never closes a silent peer during an 8s window; with the pre-fix 2s/2s
window uvicorn closes it. A genuinely-dead client still fires the
WebSocketDisconnect reap path regardless of the ping.

Note: this fixes the local Desktop case (the OP's scenario). A remote Desktop
over an authenticated public dashboard route (McCalebTheSecond's comment) keeps
the ping and needs the deeper GIL-hotspot fix — tracked separately.

Closes #53773
2026-07-03 03:33:22 +05:30
kshitijk4poor
26edfab004 chore: add trismegistus-wanderer to AUTHOR_MAP for PR #31856 salvage 2026-07-03 03:30:54 +05:30
kshitijk4poor
eb806c7f50 chore(release): add infinitycrew39 to AUTHOR_MAP (#56431 salvage) 2026-07-03 03:27:32 +05:30
kshitijk4poor
b9a197ec59 fix(agent): resolve review findings on vLLM local-context salvage
Salvage review of #56431 surfaced one Critical + two Warning issues; fix
them on top of the contributor's cherry-picked commits:

1. Critical — duplicate non-agentic warning on the interactive CLI. The new
   agent_init warning fires on every platform, but cli.py show_banner()
   already warns on CLI (richer output + /model hint), so a CLI user saw the
   warning twice per startup. Guard the agent_init emit to skip platform=="cli"
   — it now fills exactly the gateway/TUI gap the PR intended, no duplication.

2. Warning — vLLM error-parse regex under-matched. The patterns required a
   literal space before the number, so "max_model_len: 32768", "=32768",
   "(32768)", and "... is 32768" all returned None. Broaden both patterns to
   accept :/=/(/ 'is' delimiters. Add a parametrized test over all delimiter
   variants.

3. Warning — per-call live probe latency on local endpoints. The new
   reconcile-on-hit + pre-defaults step-7 probe made every local resolution
   fire a synchronous network probe (banner + /model switch + compressor
   update_model each within one startup). Add a 30s in-process TTL cache
   keyed by (model, base_url) around _query_local_context_length so back-to-
   back resolutions reuse one round-trip; not persisted to disk, so the
   reconcile freshness contract (re-probe after restart) is preserved. Add an
   autouse fixture clearing the cache between tests + TTL coverage.

Tests: 148 passed (was 138). ruff clean.
2026-07-03 03:27:13 +05:30
kshitijk4poor
65cb70b8d0 refactor(gateway): add SessionStore.peek_session_id public accessor for webhook close
Replace the webhook delivery-close path's direct reach into private
SessionStore._entries (which also bypassed the store lock) with a public,
lock-held peek_session_id(session_key) accessor. Mirrors the existing
lookup_by_session_id inverse helper. Keeps a getattr fallback for older
stores / test doubles. Adds a unit test for the accessor.
2026-07-03 03:26:53 +05:30
kshitijk4poor
de67f430b2 chore: map gumclaw@gumroad.com in AUTHOR_MAP for PR #57322 salvage 2026-07-03 03:26:53 +05:30
Gumclaw
14882bab7e fix(gateway): close webhook sessions on delivery completion so prune can reap them
Webhook deliveries created a unique one-shot session (delivery_id baked into
the session key at gateway/platforms/webhook.py:668) but the adapter fired
handle_message via asyncio.create_task WITHOUT ever ending the session
(webhook.py:713, pre-fix). Nothing else closes it: the gateway caches/expires
the agent per session_key but never calls end_session for the webhook path,
and _end_session_on_close teardown doesn't run for these fire-and-forget tasks.

SessionDB.prune_sessions (hermes_state.py:4965) only deletes rows WHERE
ended_at IS NOT NULL. So every webhook session stayed with ended_at NULL ->
unprunable -> unbounded state.db growth. This was the primary driver of the
SQLite lock-contention gateway outage.

Fix: wrap the delivery in _run_delivery_and_close, which awaits
handle_message and then (in finally, so failures still reap) calls
_end_webhook_session -> SessionDB.end_session(session_id, 'webhook_complete').
This mirrors how cron closes its session with 'cron_complete'
(cron/scheduler.py:3065). end_session is first-reason-wins and no-ops on an
already-ended row, so it never clobbers a compression/agent_close reason.

Adds tests/gateway/test_webhook_session_close.py asserting the invariant
(a completed webhook session has ended_at set + is prunable), including the
error-path case, against a real SessionStore + SessionDB.
2026-07-03 03:26:53 +05:30
infinitycrew39
53063d92b0 test(agent): cover local vLLM context-length resolution
Add regression tests for vLLM max_model_len error parsing, stale local
cache reconciliation, live probes over llama defaults, and the 64K minimum
guard on persistent cache writes.

(cherry picked from commit 1cb47ef437)
2026-07-03 03:22:51 +05:30
infinitycrew39
cecedcddf3 fix(agent): honor live vLLM context limits on local endpoints
Reconcile stale local disk cache against live vLLM/Ollama max_model_len
probes, probe local servers before the llama hardcoded default, parse
vLLM max_model_len overflow errors, and surface the non-agentic Hermes 3/4
warning at agent init on gateway/TUI.

Sub-64K live probes are returned for startup rejection but are not
persisted to the context cache — preserving the 64K minimum-context
contract instead of normalizing undersized windows as valid config.

(cherry picked from commit c3a02db4fd)
2026-07-03 03:22:51 +05:30
kchantharuan
048270fa06 fix: refresh NVIDIA featured models 2026-07-03 03:00:30 +05:30
kshitijk4poor
9f60467426 refactor(slack): extract _is_list_line helper for list-marker checks
Deduplicate the '_BULLET_RE.match or _ORDERED_RE.match' idiom used at the
list-run entry guard and the blank-line lookahead into a single helper, so
adding future marker types is a one-point change. Pure refactor, no
behavior change (22 block_kit tests still pass).
2026-07-03 02:55:22 +05:30