fix(desktop): refresh session list after delete/archive - #2
Closed
Nullstate-Studio wants to merge 315 commits into
Closed
Nullstate-Studio wants to merge 315 commits into
Nullstate-Studio wants to merge 315 commits into
Conversation
…earch#43324) The webhook 'platform disabled' card told users to enable it 'in your messaging settings' — no such page exists. The webhook platform is enabled on the Channels page (nav label), matching how every other dashboard page refers to it.
…lly tightening to 0600 (NousResearch#33699) `save_env_value()` captures the original .env file mode (e.g. 0640 for Docker volume mounts) and restores it via `os.chmod` — but then unconditionally calls `_secure_file(env_path)` on the next line, which re-tightens the mode to 0600 and defeats the entire preservation logic. The intent (preserve when `original_mode` is captured, secure otherwise) was already in the code but got short-circuited. Move `_secure_file()` into the `else` branch so it only runs when no original mode was captured — fresh `.env` files written for the first time still get the 0600 hardening treatment, but operator-set modes survive subsequent writes. Salvages NousResearch#31518 by @blut-agent (config.py portion only). Their PR also bundled unrelated lowercase-lookup changes in `hermes_cli/commands.py`; this salvage takes only the focused config fix. The commands.py changes are reasonable on their own merits but belong in a separate PR. Co-authored-by: blut-agent <278569635+blut-agent@users.noreply.github.com>
NousResearch#42578) * fix(dashboard): let _require_token endpoints work behind the OAuth gate In gated/OAuth mode (non-loopback bind without --insecure) the dashboard authenticates the SPA via a session cookie and deliberately does NOT inject the legacy ephemeral _SESSION_TOKEN into index.html. gated_auth_middleware verifies the cookie and attaches request.state.session before any non-public /api/ route runs; the legacy auth_middleware short-circuits in this mode too. But several handlers call _require_token() directly, which only validated the (absent) _SESSION_TOKEN header. So every cookie-authenticated request to those endpoints 401'd — making plugin install/enable/disable, /api/dashboard/plugins/hub, and the other _require_token routes permanently unreachable behind the gate. In the UI this surfaced as a 401: {"detail":"Unauthorized"} popup on plugin install for any publicly-bound (e.g. Fly-hosted NAS) dashboard. Fix: _require_token now defers to the active gate. When auth_required is True it accepts the request iff the gate attached a verified session (and 401s otherwise); loopback/--insecure behavior is unchanged (still validates the session token). Adds two regression tests driving the full in-process stub OAuth round trip: the install endpoint must NOT 401 a logged-in request, and must still 401 with no cookie. Verified the accept-test fails on the pre-fix code. * test(dashboard): cover the whole _require_token route class under the gate The install popup was one symptom of a class-wide bug: all 14 endpoints that call _require_token directly (API-key reveal, provider validation, the OAuth-provider connect/disconnect flow, and plugin enable/disable/update/ delete/visibility/providers) 401'd cookie-authenticated requests in gated mode. Add a parametrized test hitting a representative spread (plugins/hub, env/reveal, providers/validate, an oauth provider route, agent-plugin enable) asserting a logged-in caller is never 401'd — proving the fix covers the class, not just agent-plugins/install.
…ption (NousResearch#42090) ## What does this PR do? The voice-during-active-run feature (NousResearch#41984) changed `_enrich_message_with_transcription` so that it returns a `(enriched_text, successful_transcripts)` tuple instead of a bare string, which lets callers echo the raw transcript back to the user. The signature and every other return path were updated to match, but one branch was missed: when a successfully transcribed clip arrives with the Discord "empty content" placeholder as its caption, the method still returned the prefix string on its own. All four call sites unpack the result with `text, transcripts = await self._enrich_message_with_transcription(...)`, so that path raised `ValueError: too many values to unpack (expected 2)` and the inbound voice message was dropped instead of reaching the agent. This is a real user-facing path rather than a corner case: a Discord voice note sent without a caption is delivered as exactly that placeholder, so a captionless voice message that transcribed correctly would crash the handler precisely when transcription had worked. The fix returns the proper tuple from that branch so the placeholder is still stripped while the transcripts continue to flow back to the caller for the echo. ## Related Issue N/A ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] 🔒 Security fix - [ ] 📝 Documentation update - [ ] ✅ Tests (adding or improving test coverage) - [ ] ♻️ Refactor (no behavior change) - [ ] 🎯 New skill (bundled or hub) ## Changes Made - `gateway/run.py`: in `_enrich_message_with_transcription`, return `(prefix, successful_transcripts)` instead of a bare `prefix` from the empty-content-placeholder branch, so the contract matches the signature and the other return paths. - `tests/gateway/test_stt_config.py`: add `test_enrich_message_with_transcription_returns_tuple_for_empty_content_placeholder`, which drives a successful transcription with the placeholder caption and asserts the placeholder is stripped while the transcript is still returned. ## How to Test 1. Check out `main` and run the new test — it fails with `ValueError: too many values to unpack (expected 2)`, reproducing the crash a captionless Discord voice note would trigger. 2. Apply this change and re-run `pytest tests/gateway/test_stt_config.py -q` — all tests pass. 3. `ruff check gateway/run.py tests/gateway/test_stt_config.py` and `python scripts/check-windows-footguns.py gateway/run.py tests/gateway/test_stt_config.py` both pass. ## Checklist ### Code - [x] I've read the [Contributing Guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) - [x] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.) - [x] I searched for [existing PRs](https://github.com/NousResearch/hermes-agent/pulls) to make sure this isn't a duplicate - [x] My PR contains **only** changes related to this fix/feature (no unrelated commits) - [x] I've run `pytest tests/ -q` and all tests pass - [x] I've added tests for my changes (required for bug fixes, strongly encouraged for features) - [x] I've tested on my platform: macOS 15 (Darwin 25.5) ### Documentation & Housekeeping - [x] I've updated relevant documentation (README, `docs/`, docstrings) — or N/A - [x] I've updated `cli-config.yaml.example` if I added/changed config keys — or N/A - [x] I've updated `CONTRIBUTING.md` or `AGENTS.md` if I changed architecture or workflows — or N/A - [x] I've considered cross-platform impact (Windows, macOS) per the [compatibility guide](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md#cross-platform-compatibility) — or N/A - [x] I've updated tool descriptions/schemas if I changed tool behavior — or N/A
…ite_approval (default off) (NousResearch#43354) The shipped tri-state write_mode (on|off|approve) conflated two concepts — whether writes are enabled and whether they're gated — so 'on' (writes flow freely, gate inactive) read like 'gating is on'. Replace it with a single clear boolean gate that defaults off. memory.write_approval / skills.write_approval: false (default) — write freely; the approval gate is off (pre-gate behaviour) true — require approval: memory foreground prompts inline, memory background-review + all skill writes stage for review The old 'off = block all writes' mode is dropped; memory_enabled: false already disables memory entirely, so a third 'block' state was redundant. - tools/write_approval.py: get_write_mode/MODE_* → write_approval_enabled() bool; evaluate_gate() loses the config-driven 'blocked' path (blocked now only comes from an interactive user denial). - tools/memory_tool.py, tools/skill_manager_tool.py: comment + behaviour follow. - hermes_cli/config.py: memory/skills write_mode → write_approval (False); _config_version 28→29 with a 28→29 migration that renames any persisted write_mode (approve→true, on/off/unset→false) and drops the old key. - slash commands: '/memory|/skills mode <on|off|approve>' → 'approval <on|off>' ('mode' kept as a back-compat alias); set_mode_fn callback now takes a bool. - write_approval_commands.py, cli_commands_mixin.py, gateway/slash_commands.py, commands.py: handlers + registry args/subcommands updated. - docs + tests rewritten for the boolean model; added migration tests.
…ot guard xAI's consent page renders the authorization code in-page instead of redirecting to the loopback callback, so the listener just hangs and the manual-paste flow demands a callback URL that never contains the token. - auth.py: poll stdin non-blockingly while waiting for the xAI loopback callback; accept a pasted bare Grok Build code and substitute the locally generated state (PKCE code_verifier still binds the exchange). No need to wait for timeout or re-run with --manual-paste. - computer_use: parse PNG/JPEG dimensions from base64 and fall back to the text/AX/SOM payload when the screenshot is below the provider minimum (8x8), which xAI rejects with HTTP 400. - model_setup_flows.py: xAI credential reuse prompt uses the standard radio picker via a shared _prompt_auth_credentials_choice helper. - main.py: thread a title through _prompt_provider_choice; re-home the helper import (flows live in model_setup_flows.py post-decomposition). Salvaged from NousResearch#36781 onto current main (contributor's main.py edits re-homed to model_setup_flows.py, where the flows were extracted since the PR opened).
Maps the salvaged NousResearch#36781 commit author email to the GitHub login so the release attribution + CI author check resolve.
…custom endpoints
Third-party OpenAI-compatible endpoints (self-hosted gateways, OpenRouter,
Azure proxies) fronting gpt-4o / gpt-4.1 / gpt-5+ / o1-o4 models silently
received max_tokens and 400'd with unsupported_parameter, because the three
kwarg-selection sites only checked base_url_hostname(...) == "api.openai.com"
and fell through to max_tokens on every other host. The constraint is
enforced server-side by the model family, not by the URL, so name-based
detection is required as a fallback.
Changes:
- utils.py: new shared helper model_forces_max_completion_tokens(model) that
prefix-matches gpt-4o, gpt-4.1, gpt-5, o1, o3, o4 families on normalized
(lowercased, vendor-prefix-stripped) names.
- run_agent.py: _max_tokens_param ORs the helper into the URL check.
- agent/auxiliary_client.py:
- auxiliary_max_tokens_param gains an optional keyword-only model arg.
- _build_call_kwargs inline branch applies the same check for both
provider == "custom" and non-custom paths.
Tests:
- tests/test_model_forces_max_completion_tokens.py: 31 new cases covering
positive families, negatives (classic gpt-4, claude, llama, mistral, qwen,
deepseek), vendor prefixes, case-insensitivity, whitespace, None/empty,
and substring-not-prefix guards.
- tests/run_agent/test_run_agent.py::TestMaxTokensParam: 5 new model-based
cases (custom + gpt-5.4, openrouter + gpt-4o-mini, custom + o1-preview,
classic gpt-4-turbo keeps max_tokens, llama3 keeps max_tokens).
- tests/agent/test_auxiliary_client.py::TestAuxiliaryMaxTokensParam: new
class, 7 tests covering the URL x model matrix.
…ntext overflow A GPT-5 model rejecting max_tokens returns a 400 whose message contains the literal substring 'max_tokens' — one of the _CONTEXT_OVERFLOW_PATTERNS. The 400 path in _classify_400 checked overflow patterns before any request-validation check (which only existed on the 5xx path), so the parameter error was routed into the compression loop, re-sent with the same bad param, and ended in 'Cannot compress further' on a tiny context. Hoist a request-validation guard (unsupported/unknown parameter) above the context-overflow check in _classify_400. Deliberately excludes the generic invalid_request_error code, which OpenAI also stamps on real overflow 400s, so genuine overflows still compress. Pairs with the max_completion_tokens param fix that stops the bad request at the source. Also adds AUTHOR_MAP entry for the salvaged PR NousResearch#13902 commit.
parallel_search_sources accepted an overall_timeout but never honoured it. The ThreadPoolExecutor ran inside a `with ... as pool` block, whose __exit__ calls shutdown(wait=True); even after as_completed() raised TimeoutError on schedule, leaving the block blocked the caller until every worker finished. A single slow source (e.g. ClawHub) therefore stalled the entire browse for minutes. Manage the executor manually and shut it down with wait=False, cancel_futures=True in a finally, so the timeout actually returns and not-yet-started work is dropped. ClawHubSource._load_catalog_index walked up to 750 sequential pages with no wall-clock bound (each request under its own timeout=30, so nothing errored), and wrote the result to the index cache unconditionally — so an interrupted or slow walk poisoned the cache with a partial catalog. Add a CATALOG_WALK_BUDGET_SECONDS deadline that breaks the walk early, and only write the cache when the walk reaches a natural stop (cursor exhausted or page cap), never on a budget-truncated walk. Adds regression tests covering both bugs (timeout honoured + slow source flagged; budget abort does not poison cache) plus their happy-path invariants.
The voice inactivity timer (VOICE_TIMEOUT) only counted the bot's OWN audio playback as activity. Under /voice off (text-only replies, but still in the channel — leaving is /voice leave) nothing ever reset it, so every 300s the bot disconnected and spammed "Left voice channel (inactivity timeout)." The adapter now learns the live voice-reply mode via a getter wired from run.py and skips the auto-disconnect while mode is off. It also resets the timer when a user actually speaks to the bot, so an active listener (incl. voice-on text-only sessions that never play audio) isn't dropped mid-conversation.
Assert the inactivity handler skips disconnect (and the channel spam) when the voice-mode getter reports "off", and still disconnects on genuine inactivity when the mode is active.
… works end-to-end DISCORD_ALLOWED_ROLES was checked by the Discord adapter (_is_allowed_user) but gateway._is_user_authorized only read DISCORD_ALLOWED_USERS, so role-authorized users were rejected with "Unauthorized user" at the gateway layer despite passing the adapter gate. - Add role_authorized: bool = False to SessionSource - Add role_authorized param to build_source (base.py) - Compute _role_authorized in on_message when user passes via role not user ID - Thread _role_authorized through _handle_message -> build_source - Check source.role_authorized early in _is_user_authorized (run.py) Fixes NousResearch#33952
Compare source.role_authorized with 'is True' so a MagicMock source (test fixtures that build bare runners via object.__new__) doesn't auto-truthy through the gate. The real SessionSource field is a bool, so production behavior is unchanged. Fixes test_signal_in_allowlist_maps.
Rebased onto current main and re-ported across the restructured surfaces: model flows now thread confirm_provider/base_url/api_key through hermes_cli/model_setup_flows.py, the Discord picker lives in plugins/platforms/discord/adapter.py, and the web dashboard picker applies chat-mode switches via config.set so the expensive-model confirmation can ride the response. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-ups on top of NousResearch#26016's expensive-model guard: - gateway/slash_commands.py: typed '/model <name>' now routes through the expensive-model confirmation gate (slash-confirm buttons / text fallback) instead of bypassing the guard the pickers enforce. Cancel leaves the session override and --global config untouched. - telegram/discord/web_server: run expensive_model_warning() via asyncio.to_thread — it can hit models.dev or a /models endpoint on a cache miss, which would otherwise block the event loop. - telegram: picker callback no longer toasts 'Model switched!' when the switch callback raised (both mm: and mc: paths). - tests: new tests/gateway/test_model_command_expensive_confirm.py pins the typed-path gate (prompt, confirm-once, cancel, cheap-model no-op).
Backend selection ordered firecrawl (including the Nous-managed-tool-gateway probe) ahead of explicit-credential backends, so a user who had both a Nous OAuth token AND a TAVILY_API_KEY (or EXA/PARALLEL key) got firecrawl auto-selected — then the request failed at runtime because the free Nous tier does not include web search, and there is no fallback to the next available backend. Explicit user setup lost to a managed convenience. Reorder so direct-credential backends (tavily > exa > parallel > firecrawl- direct) are tried first, then the managed-gateway firecrawl probe, then free-tier fallbacks. Behaviour for users with only Nous OAuth (no explicit key) is unchanged — firecrawl-via-gateway is still selected. Behaviour change to flag: a user with BOTH a Nous OAuth token AND a TAVILY_API_KEY (or EXA/PARALLEL key) now gets the explicit backend instead of the managed gateway. This matches the principle of least surprise — a user does not set TAVILY_API_KEY without intent — and sidesteps the silent runtime failure of the gateway path on free tiers.
…rt (NousResearch#43395) Browse renders one page but the cold-cache fallback walked the entire 50k+ ClawHub catalog, then sliced off the first N — pure waste behind the 12s budget band-aid. _load_catalog_index now takes max_items: browse's empty-query path bounds the walk to its limit and stops early; the offline index builder still passes limit=0 (unbounded) and walks to exhaustion. A bounded walk is partial, so it is not written to the shared full-catalog cache (same poison-guard as the budget-truncated case).
…rch#43398) do_browse waited on a frozen 'Fetching skills...' spinner while sources resolved, so a slow source looked like a hang. parallel_search_sources already exposes an on_source_done(sid, count) callback fired as each source completes — wire it into the status line so it ticks off sources live (official (12), + github (4), + clawhub (500)). The page is still rendered once, after the full set is merged and trust-sorted, so browse's official-first ordering and pagination contract are untouched.
Outline codicons read too thin at conversation-tool scale; a scoped filled modifier thickens tool-row and code-card icons without changing icon semantics elsewhere in the shell.
…onboarding UI Follow-ups to the salvaged Telegram QR onboarding auto-restart: - _spawn_gateway_restart() reuses a live in-flight 'hermes gateway restart' child instead of spawning a second racing one (stale cached frontend + new backend both requesting a restart, or restart-button double-click). Both /api/gateway/restart and the onboarding apply path go through it. - ChannelsPage polls /api/actions/gateway-restart/status after a server-initiated restart and surfaces a non-zero exit (e.g. systemd linger missing) via the manual-restart banner, since restart_started only means the child spawned. - Test for the reuse path + _ACTION_PROCS isolation in existing tests.
Replace the earlier text-stroke approach (which only bolds outline codicons — a font glyph has no fillable region) with dedicated solid SVG glyphs for tool rows. Adds ToolIcon, keyed by the same names as TOOL_META, with a codicon fallback for uncovered tools.
Sticky human bubbles park at --sticky-human-top (~4px), sliding under the titlebar's -webkit-app-region:drag strips. Electron resolves drag regions at the compositor level — z-index and pointer-events don't apply — so clicking a stuck bubble dragged the window instead of opening the edit composer. Add no-drag to the shared bubble base class (read-only bubble + edit composer). Covers the runtime side with a test: clicking a user bubble opens the inline edit composer through both the incremental external-store runtime and the stock one. (cherry picked from commit db4e1f4)
…ol-codicons-filled style(desktop): filled glyphs for in-thread tool icons
…indows (NousResearch#43439) `hermes setup` (and other banner-printing commands) crash with an unhandled UnicodeEncodeError on Linux hosts whose locale selects a non-UTF-8 codec — e.g. a fresh Raspberry Pi / minimal Debian with a latin-1 or C/POSIX locale. The setup wizard prints box-drawing characters (┌│├└─) and the ⚕ glyph before any stream repair runs, so the command dies before it can start. The existing _ensure_utf8() shim already knew how to re-wrap the standard streams as UTF-8, but it returned early on `sys.platform != "win32"`, so the identical crash class on Linux was never covered. - Drop the win32 gate: repair any stdout/stderr whose encoding is not UTF-8. - Prefer TextIOWrapper.reconfigure() so the stream object is fixed in place (cached sys.stdout references keep working); fall back to reopening the fd with closefd=False (the CPython-recommended safe variant). - Use errors="replace" — matching the sibling hermes_cli/stdio.py shim — so a stray un-encodable byte degrades gracefully instead of crashing. - Only set the PYTHONUTF8/PYTHONIOENCODING child-process hints when a repair actually happened, so a healthy UTF-8 host sees zero footprint (no stream swap, no env mutation). This is intentionally the earliest, platform-agnostic guard, running at import time before any banner prints. hermes_cli/stdio.py::configure_windows_stdio() still runs later from the entry points for the Windows-only extras (console code-page flip, EDITOR default, PATH augmentation); it early-returns on non-Windows and its stream reconfigure is an idempotent no-op once we've already repaired the streams here. Add regression tests covering latin-1 and ascii/POSIX streams, the reconfigure fallback, already-UTF-8 no-op (identity preserved + no env mutation), the repair-sets-env and respects-explicit-env contracts, and hostile/None streams.
Prevents "Future attached to a different loop" errors when _sidecar_call is invoked from a worker thread via _run_async in send_message_tool. The persistent _http_client remains in use for the inbound streaming loop, which always runs on the gateway's loop.
Add `action='react'` to `send_message` tool and expose `add_reaction`/ `remove_reaction` on the Photon adapter. - Track latest inbound message id per chat (`_last_inbound_by_chat`, bounded to 200 entries) so the agent can react without threading message ids through tool calls - New `add_reaction`/`remove_reaction` public methods on PhotonAdapter; unlike the lifecycle tapbacks, these are not gated by PHOTON_REACTIONS - `send_message` gains `action='react'` with `emoji` and optional `message_id` params; resolves target via existing channel-directory and home-channel logic; requires a live gateway adapter
Inbound events key the tracker by the DM chat GUID (any;-;+1555...), but home-channel react calls address the same space by bare E.164 — normalize both to the phone so add_reaction's last-inbound default resolves regardless of which form the caller uses (mirrors the sidecar's phoneTargetFromSpaceId). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atch tests Follow-up for salvaged PR NousResearch#44486: the adapter shipped remove_reaction but the tool only exposed 'react'. Generalize _handle_react(remove=) and add tool-level dispatch tests for react/unreact (missing from the original PR).
…fallback Widen the salvaged NousResearch#12851 fix to match the established classification pattern (WhatsApp/Slack/BlueBubbles/Mattermost): video/* -> VIDEO, and any remaining MIME type falls through to DOCUMENT instead of TEXT, so exotic types still trigger run.py's document-context injection.
Email cached document attachments and placed them in media_urls, but msg_type only flipped on image attachments — documents stayed TEXT and run.py's document-context injection (gated on MessageType.DOCUMENT) silently dropped them. Same bug class as Signal NousResearch#12845. DOCUMENT wins over PHOTO for mixed attachments since image handling keys off per-path mime types while document injection gates strictly on message_type.
SimpleX tagged unknown files application/octet-stream in media_types but classification only handled audio/image, leaving msg_type TEXT — run.py never injected the document context. Same bug class as NousResearch#12845.
Avoid recompiling the pattern on every _serialize_for_summary call; name it beside _PATH_MENTION_RE with the NousResearch#14665 rationale.
…ousResearch#44702) Modal prompt panels (dangerous-command approval, clarify questions) live in the prompt_toolkit layout and vanish on the next repaint, leaving no trace of the question or the decision in chat history. Emit a dim one-line summary after each prompt resolves: ⚠ Approval: <command> → allowed for session ? Clarify: <question> → <answer> Gated on display.persist_prompts (default true). Detail and outcome are whitespace-collapsed and capped at 120 chars.
…usResearch#44776) * feat(billing): /usage → portal top-up browser handoff Add the terminal side of the billing slice (phase 2a): start a top-up by throwing the user to the portal billing page with the top-up modal open. The terminal does not confirm, poll, or track payment — checkout completes in the browser and the next /usage shows the new balance. - nous_account.py: parse organisation.slug/name from /api/oauth/account into NousPortalAccountInfo; add nous_portal_topup_url() building the org-pinned {base}/orgs/{slug}/billing?topup=open with a null-slug fallback to the legacy {base}/billing?topup=open (never /orgs/None/...). - portal_cli.py: 'hermes portal topup' — fresh account fetch, identity line (Topping up as <email> / org <name>), browser open with printed-URL fallback, no-wait closing copy. No polling/confirmation (deferred to 2b). - account_usage.py: the shared /usage credits block now links the org-pinned top-up URL (auto-opens the modal) + points to the command. Depends on NAS NousResearch#409 (organisation.slug/name + ?topup=open). Do not merge until that is live on the target env; until then /api/oauth/account returns organisation: { id } only and the URL falls back to legacy. * feat(billing): /credits command for balance + top-up handoff Replace the standalone `hermes portal topup` subcommand with an in-session /credits slash command — a focused money surface (balance in, top-up out) that works in the CLI, TUI, and every messaging platform from one registry entry. - commands.py: register /credits (Info category). Slack is at its 50-slash cap, so /credits is routed via /hermes credits on Slack only (new _SLACK_VIA_HERMES_ONLY set) to avoid clamping a canonical command off the native list and breaking Telegram parity; native everywhere else. - account_usage.py: build_credits_view() — one portal fetch → balance lines + identity line + org-pinned top-up URL + depleted flag, consumed by all surfaces. Reuses the same snapshot/URL builder as /usage so numbers match. - cli.py: _show_credits() — balance block + identity line + 3-button panel (Open top-up / Copy link / Cancel) via the existing prompt_toolkit modal. ASK, never auto-launch; headless falls back to printing the URL. - gateway/slash_commands.py: _handle_credits_command() — renders the block + tappable top-up URL + no-wait copy; works on button and plain-text platforms. - /usage credits line now points to /credits. - Retire `hermes portal topup` (portal_cli.py back to baseline); the engine (slug/name parse + nous_portal_topup_url) stays as the shared core. No polling, no payment confirmation (billing phase 2a). Depends on NAS NousResearch#409. * fix(credits): /credits works in the TUI slash-worker (non-interactive) In the TUI, /credits runs in the slash-worker subprocess where there is no live prompt_toolkit app and stdin is the JSON-RPC pipe. _show_credits called the 3-button modal unconditionally, which fell back to reading stdin → exception → slash.exec rejected → the command produced no output (only the pre-existing 'Credit access paused' banner showed). - _show_credits: when self._app is None (TUI worker / piped / non-interactive), render the text variant — balance block + tappable top-up URL + no-wait line, same affordance as the messaging surfaces — and skip the modal entirely. The 3-button panel still renders in the interactive CLI. - Depleted banner copy: 'run /usage for balance' → 'run /credits to top up' now that /credits is the dedicated money surface (+ tests). - Regression tests: _show_credits with self._app=None renders text and never invokes the modal; logged-out path. * feat(tui): credits.view RPC for the /credits tappable top-up button Add a credits.view JSON-RPC method returning the structured CreditsView (logged_in, balance_lines, identity_line, topup_url, depleted) so the TUI can render a clickable <Link> top-up button instead of plain text. Account- independent (portal fetch gated on a logged-in Nous account), fail-open to {logged_in: false} on any hiccup. Mirrors session.usage's credits-block pattern. Frontend (TUI-local /credits command + Ink component) lands separately. * feat(tui): /credits command with keyboard-driven top-up confirm TUI-local /credits: fetches the structured balance via the credits.view RPC, prints the balance + identity + top-up URL, then arms the EXISTING confirm overlay (Enter = open top-up in browser via openExternalUrl, Esc = cancel). Reuses ConfirmReq — no new overlay component/state/input handler. Headless (openExternalUrl returns false) falls back to printing the URL. - gatewayTypes.ts: CreditsViewResponse. - commands/credits.ts: the command (mirrors /status's rpc+guarded pattern). - registry.ts: register creditsCommands. - test: balance+overlay armed, headless fallback, no-url, logged-out (4 cases). Matches the CLI /credits 'Enter to open' affordance. Phase 2a: no polling.
…CUMENT (NousResearch#44778) The Teams adapter only handled image/* attachments — documents (the application/vnd.microsoft.teams.file.download.info consent-free download payload and any direct-URL non-image attachment) never reached media_urls at all, so run.py's document-context injection had nothing to surface. Completes the class-wide sweep from PR NousResearch#44695 (Signal/Email/SimpleX). - download.info attachments: fetch the pre-authed SharePoint downloadUrl (SSRF-guarded, same guard chain as base.py cache_*_from_url) and route through cache_media_bytes - direct-URL non-image attachments: same fetch + classify path - skip Teams' text/html message-body mirror and adaptive-card attachments - DOCUMENT > PHOTO > VIDEO > AUDIO precedence for mixed attachments, matching the Email precedence rationale from NousResearch#44695
* feat(yuanbao): support wechat forward msg * feat(yuanbao): support wechat forward msg --------- Co-authored-by: loongfay <izhaolongfei@gmail.com>
… .env (NousResearch#44792) Two halves of the same community report (dashboard Profile Builder): 1. A fresh dashboard/CLI-created profile got no .env file unless cloned, so it silently inherited API keys and messaging tokens from the shell environment / root install. create_profile() now seeds a placeholder .env (0600) for non-clone profiles, matching the SOUL.md seeding. 2. The Channels endpoints (/api/messaging/platforms GET/PUT/test) were not profile-scoped: they read/wrote the dashboard process's own .env via load_env()/save_env_value() regardless of the global profile switcher. They now accept the standard optional profile param (body beats query on the PUT, matching other scoped writes) and run inside _profile_scope(). When scoped, the payload no longer falls back to os.environ or load_gateway_config()'s env-override layer — both carry the ROOT install's credentials and would misreport them as the profile's. /api/messaging/platforms added to PROFILE_SCOPED_PREFIXES so the sidebar switcher scopes the Channels page automatically.
macOS Desktop backend processes can still miss Apple Silicon Homebrew paths even after adding Hermes-managed Node and venv bins. That leaves `/codex-runtime on` unable to find a Homebrew-installed `codex` binary at `/opt/homebrew/bin/codex`. Add a small testable backend env helper that builds the dashboard subprocess environment in one place. It prepends Hermes-managed Node and venv bins, appends missing POSIX sane PATH entries individually, preserves caller precedence without duplicates, and keeps Windows PATH casing/delimiters intact. Wire both source-checkout and active-install backend descriptors through the helper, and add Node regression coverage to the desktop platform test suite.
…ionMatchesStoredId The post-delete server refresh was removed in 7a2d498; this restores it with the new listAllProfileSessions API, scoped to the deleted session's profile. Also extracted sessionMatchesStoredId from use-session-actions.ts into store/session.ts alongside sessionPinId — the same lineage-aware matching was inlined in desktop-controller.tsx. Centralizing it keeps all callers in sync and prevents the logic from diverging again.
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
76 |
invalid-argument-type |
51 |
unresolved-import |
50 |
unsupported-operator |
24 |
invalid-assignment |
21 |
not-subscriptable |
6 |
invalid-method-override |
5 |
not-iterable |
2 |
invalid-return-type |
2 |
no-matching-overload |
2 |
unresolved-reference |
2 |
call-non-callable |
2 |
invalid-parameter-default |
1 |
unused-awaitable |
1 |
First entries
gateway/slash_commands.py:1249: [invalid-assignment] invalid-assignment: Object of type `dict[Unknown, Unknown]` is not assignable to attribute `_pending_model_notes` on type `Self@_handle_model_command & ~<Protocol with members '_pending_model_notes'>`
tests/tools/test_write_approval.py:204: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["Approved 2"]` and `str | None`
gateway/slash_commands.py:490: [unresolved-attribute] unresolved-attribute: Object of type `Self@_gateway_session_origin_for_id` has no attribute `session_store`
tests/cron/test_suggestions.py:198: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
tests/plugins/platforms/photon/test_reactions.py:133: [invalid-assignment] invalid-assignment: Object of type `def _boom(path: str, body: dict[str, Any]) -> CoroutineType[Any, Any, dict[str, Any]]` is not assignable to attribute `_sidecar_call` of type `def _sidecar_call(self, path: str, body: dict[str, Any]) -> CoroutineType[Any, Any, dict[str, Any]]`
tests/tools/test_write_approval.py:215: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["Rejected"]` and `str | None`
tests/test_toolsets.py:211: [not-iterable] not-iterable: Object of type `str | list[str] | list[Unknown] | bool` may not be iterable
gateway/platforms/whatsapp_cloud.py:1077: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`
tests/gateway/test_matrix.py:3354: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
tests/hermes_cli/test_tools_config.py:915: [invalid-argument-type] invalid-argument-type: Argument to `set.__init__` is incorrect: Expected `Iterable[str | Unknown]`, found `str | list[str] | list[Unknown] | bool`
gateway/platforms/whatsapp_common.py:252: [unresolved-attribute] unresolved-attribute: Object of type `Self@_message_matches_mention_patterns` has no attribute `_mention_patterns`
tests/hermes_cli/test_web_server_skills_profiles.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_dashboard_unified_launch.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_whatsapp_cloud_setup.py:211: [unresolved-attribute] unresolved-attribute: Attribute `startswith` is not defined on `None` in union `str | None`
hermes_state.py:821: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `Connection`, found `None | Connection`
tests/tools/test_discord_tool.py:637: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["discord"]` and `str | list[str] | list[Unknown] | bool`
tests/tools/test_session_search.py:459: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `None | Connection`
tests/plugins/platforms/photon/test_markdown.py:34: [invalid-assignment] invalid-assignment: Object of type `def _fake_call(path: str, body: dict[str, Any]) -> CoroutineType[Any, Any, dict[str, Any]]` is not assignable to attribute `_sidecar_call` of type `def _sidecar_call(self, path: str, body: dict[str, Any]) -> CoroutineType[Any, Any, dict[str, Any]]`
gateway/slash_commands.py:1389: [unresolved-attribute] unresolved-attribute: Object of type `Self@_handle_model_command` has no attribute `_request_slash_confirm`
tests/gateway/test_matrix.py:1911: [invalid-assignment] invalid-assignment: Object of type `def capture(event) -> CoroutineType[Any, Any, Unknown]` is not assignable to attribute `handle_message` of type `def handle_message(self, event: MessageEvent) -> CoroutineType[Any, Any, None]`
tests/agent/test_compress_focus.py:148: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["- fourth"]` and `Unknown | None`
tests/hermes_cli/test_ensure_utf8_locale.py:84: [unresolved-attribute] unresolved-attribute: Attribute `getvalue` is not defined on `TextIO` in union `TextIO | Any`
tests/gateway/test_matrix.py:3951: [invalid-assignment] invalid-assignment: Object of type `AsyncMock` is not assignable to attribute `_get_display_name` of type `def _get_display_name(self, room_id: str, user_id: str) -> CoroutineType[Any, Any, str]`
hermes_cli/web_server.py:9752: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `None | Connection`
tests/gateway/test_telegram_voice_v0_regressions.py:67: [invalid-argument-type] invalid-argument-type: Argument to function `GatewaySlashCommandsMixin._handle_voice_command` is incorrect: Expected `MessageEvent`, found `SimpleNamespace`
... and 220 more
✅ Fixed issues (73):
| Rule | Count |
|---|---|
invalid-argument-type |
29 |
unresolved-attribute |
26 |
unresolved-import |
6 |
invalid-assignment |
3 |
invalid-return-type |
3 |
unresolved-reference |
2 |
call-non-callable |
2 |
invalid-parameter-default |
1 |
unsupported-operator |
1 |
First entries
gateway/session.py:268: [unresolved-attribute] unresolved-attribute: Attribute `title` is not defined on `set[Unknown]` in union `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
hermes_cli/cli_agent_setup_mixin.py:509: [unresolved-attribute] unresolved-attribute: Attribute `commit` is not defined on `None` in union `Connection | None`
tests/gateway/test_title_command.py:174: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.create_session` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
gateway/run.py:7267: [invalid-argument-type] invalid-argument-type: Argument to function `get_disabled_skill_names` is incorrect: Expected `str | None`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | (set[Unknown] & ~AlwaysFalsy)`
tests/agent/test_compression_concurrent_fork.py:85: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `Connection | None`
gateway/platforms/api_server.py:707: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `def create_job(prompt: str | None, schedule: str, name: str | None = None, repeat: int | None = None, deliver: str | None = None, origin: dict[str, Any] | None = None, skill: str | None = None, skills: list[str] | None = None, model: str | None = None, provider: str | None = None, base_url: str | None = None, script: str | None = None, context_from: str | list[str] | None = None, enabled_toolsets: list[str] | None = None, workdir: str | None = None, profile: str | None = None, no_agent: bool = False) -> dict[str, Any]`
tests/hermes_cli/test_web_server.py:659: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `Connection | None`
skills/red-teaming/godmode/scripts/auto_jailbreak.py:540: [unresolved-reference] unresolved-reference: Name `escalate_encoding` used when not defined
hermes_state.py:764: [unresolved-attribute] unresolved-attribute: Attribute `cursor` is not defined on `None` in union `Connection | None`
gateway/run.py:6286: [invalid-argument-type] invalid-argument-type: Argument to bound method `PairingStore.generate_code` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
hermes_cli/cli_agent_setup_mixin.py:504: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `Connection | None`
gateway/channel_directory.py:87: [invalid-argument-type] invalid-argument-type: Argument to function `_build_from_sessions` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
gateway/run.py:6283: [invalid-argument-type] invalid-argument-type: Argument to bound method `PairingStore._is_rate_limited` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
gateway/run.py:5580: [invalid-argument-type] invalid-argument-type: Argument to bound method `GatewayRunner._update_platform_runtime_status` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
tests/cron/test_cron_profile.py:202: [unresolved-attribute] unresolved-attribute: Unresolved attribute `AIAgent` on type `ModuleType`
plugins/platforms/photon/adapter.py:148: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["home_channel"]` and value of type `dict[str, str]` on object of type `dict[str, str]`
skills/red-teaming/godmode/scripts/auto_jailbreak.py:620: [unresolved-reference] unresolved-reference: Name `score_response` used when not defined
gateway/run.py:11290: [invalid-argument-type] invalid-argument-type: Argument to function `set_session_vars` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
cli.py:9432: [invalid-argument-type] invalid-argument-type: Argument to function `_wrap_panel_text` is incorrect: Expected `str`, found `str | list[str] | int | Queue[Unknown] | Unknown`
skills/red-teaming/godmode/scripts/godmode_race.py:27: [unresolved-import] unresolved-import: Cannot resolve imported module `openai`
skills/red-teaming/godmode/scripts/auto_jailbreak.py:25: [unresolved-import] unresolved-import: Cannot resolve imported module `openai`
skills/red-teaming/godmode/scripts/auto_jailbreak.py:383: [invalid-parameter-default] invalid-parameter-default: Default value of type `None` is not assignable to annotated parameter type `str`
tests/gateway/test_whatsapp_group_gating.py:372: [invalid-argument-type] invalid-argument-type: Argument to function `WhatsAppAdapter._is_broadcast_chat` is incorrect: Expected `str`, found `None`
tests/cron/test_cron_profile.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/cron/test_cron_profile.py:266: [unresolved-import] unresolved-import: Cannot resolve imported module `dotenv`
... and 48 more
Unchanged: 5452 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Nullstate-Studio
pushed a commit
that referenced
this pull request
Jun 22, 2026
…bound/outbound round-trip (NousResearch#48828) * fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect Three bugs blocked a self-provisioned hosted gateway from ever establishing its inbound relay WS (found while standing up the live staging end-to-end). Each masked the next; all three are needed for inbound to work. 1. RELAY platform never enabled in config.platforms (gateway/config.py). register_relay_adapter() puts the adapter in the platform_registry, but start_gateway()'s connect loop iterates self.config.platforms — which never contained Platform.RELAY. So the adapter was "registered" but never connected (logs showed "relay adapter registered" then "No messaging platforms enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env) or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/ single-tenant gateways unaffected). 2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py). The relay URL is configured once as the http(s):// base (used as-is for the provision POST), but websockets.connect rejects http(s):// with "scheme isn't ws or wss". Fix: _ws_dial_url converts https->wss / http->ws. 3. /relay path not appended (same helper). The connector mounts its WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/" -> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL already carrying ws(s):// and/or /relay is unchanged, so provision's _provision_url (which derives /relay/provision from either form) still works. Why the cross-repo E2E missed #2/NousResearch#3: the stub connector binds ws://host:port and its websockets.serve accepts ANY path, so neither the scheme nor the /relay path was exercised. Real connector needs both. Verified live on staging hermes-agent-stg-automated-perception-5054: after the fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" -> "Gateway running with 1 platform(s)" against wss://gateway-gateway.staging-nousresearch.com/relay, stable. Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py) and RELAY-platform-enablement cases for env + yaml + absent (test_config.py). Full gateway/relay + config suites green (191 passed). Relay-adapter lane. EXPERIMENTAL. * fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord -> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress was declined by the connector: "discord egress declined: target not routed to an onboarded tenant". Cause: the connector's routedEgressGuard resolves the owning tenant from the OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The gateway's generic delivery path builds outbound metadata via run.py _thread_metadata_for_source, which only carries thread_id (and returns None entirely for a non-threaded message) — so guild_id never reached the connector, tenant resolution failed, and the shared bot refused to post. Fix (relay-adapter-local, no perturbation of the generic delivery path or other platforms): RelayAdapter learns chat_id -> guild_id from each inbound event (_capture_scope) and re-attaches it to the outbound action's metadata in send() (_with_scope) when not already present. No-op for chats we never saw inbound (e.g. DMs) and never overwrites an explicit guild_id. Verified live on staging hermes-agent-stg-automated-perception-5054: an @mention in #general now produces a visible bot reply — full multi-tenant relay round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS -> reply egress -> Discord). Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id preserved (test_relay_adapter.py). Full relay + config suites green (160 passed). Relay-adapter lane. EXPERIMENTAL.
Nullstate-Studio
pushed a commit
that referenced
this pull request
Jun 22, 2026
When context compression rotates a session, the original is ended and the continuation is auto-numbered (e.g. "name" -> "name #2"). The session list projects the ended root behind its live tip, so the user never sees the predecessor. But set_session_title's uniqueness check compared against ALL sessions, so renaming the visible tip back to "name" dead-ended with "Title 'name' is already in use by session <id the user can't find>". When the conflicting title is held by a compression ancestor of the session being renamed, transfer the title instead of raising: clear it from the ended predecessor and apply it to the continuation. Uniqueness is preserved (still exactly one session carries the title) and the parent-link lineage is untouched, so resume-by-title and tip projection keep working. Genuine conflicts with unrelated sessions, and with non-compression children (delegate/branch), still raise as before.
Nullstate-Studio
pushed a commit
that referenced
this pull request
Jun 22, 2026
…id (NousResearch#38763) Context compression today rewrites the message list AND rotates the session id — it ends the session, forks a parent_session_id child, and renumbers the title (name -> name #2). That moving identity key is the root cause of a whole bug cluster: /goal lost (NousResearch#33618), pending response lost at the split (NousResearch#14238), orphan sessions (NousResearch#33907), TUI sid desync (NousResearch#36777), FTS search gaps + duplicate sidebar entries (NousResearch#45117), null continuation cwd (NousResearch#42228), and title-rename dead-ends (NousResearch#48989). It also forced a large defensive apparatus (compression lock, contextvar/env/ logging triple-sync, orphan finalization, gateway SessionEntry re-propagation, tip projection) whose only job is surviving a mid-conversation id change. Add a compression.in_place config flag (default False during rollout). When True, compaction rewrites the transcript and rebuilds the system prompt but keeps the SAME session_id: no end_session, no child row, no title renumber, no contextvar/logging re-sync, no memory/context-engine session-switch. The conversation keeps one durable id for life, like Claude Code / Codex. Compaction is lossy by design — the pre-compaction transcript is summarized away, not archived. The rotation path is unchanged when the flag is off (moved verbatim into an else branch). Staged rollout: this PR ships the option behind a default-off flag for live validation; a follow-up flips the default and deletes the now-redundant rotation machinery, superseding the 14 open band-aid PRs in this area. - hermes_cli/config.py: add compression.in_place (default False), documented - agent/agent_init.py: resolve the flag -> agent.compression_in_place - agent/conversation_compression.py: branch compress_context() on the flag - tests/run_agent/test_in_place_compaction.py: in-place invariants + rotation regression guard + config default The pre-flush of current-turn messages (NousResearch#47202) runs in BOTH modes, so no boundary data loss. Prompt-cache invariant preserved: the system-prompt rebuild is the same single sanctioned invalidation that already happens during compaction — no NEW invalidation. Message alternation preserved.
Nullstate-Studio
pushed a commit
that referenced
this pull request
Jul 15, 2026
…eation snapshot (NousResearch#44585) An unpinned cron job follows the global default provider (config.yaml model.default + resolve_runtime_provider). If that global state is changed after the job is created — e.g. a temporary switch to a paid provider like nous/claude-fable-5 — the job silently inherits it on its next tick and spends real money. This is the reported $7.73 incident: a job created under a free/default provider later inherited a temporary paid switch. Fix (ask #1 only) preserves the legitimate "unpinned job should follow model.default" use case by detecting *drift* rather than freezing the model: - create_job (cron/jobs.py): for UNPINNED, agent-backed jobs (no explicit provider, not no_agent), snapshot the provider that resolution WOULD pick right now into a new optional `provider_snapshot` field, resolved via the same resolve_runtime_provider() path the ticker uses. Fail-open to None on any resolution error so job creation never breaks. - run_job (cron/scheduler.py): right after runtime resolution, if the job has a provider_snapshot AND is unpinned AND the currently-resolved provider DIFFERS from the snapshot, fail closed for that run — make no paid call and deliver a loud, actionable alert naming both providers and telling the user to pin explicitly (`cronjob action=update job_id=.. provider=..`). Back-compat: jobs with no snapshot (pre-existing jobs, no_agent jobs, or any job whose creation-time resolution failed) behave exactly as before — the guard only engages when a snapshot exists. Explicitly-pinned jobs (job.provider set) are unaffected since they don't drift with global state. Tests: tests/cron/test_cron_provider_pin.py covers snapshot-matches (runs), snapshot-differs (fail closed, no agent constructed), no-snapshot back-compat, None-snapshot back-compat, explicitly-pinned (runs regardless), plus create_job snapshot capture/skip/fail-open. The fail-closed case is load-bearing (fails without the guard). Issue NousResearch#44585 asks #2-4 (hard-stop a running job, gateway-stop containment, fail-closed on provider mutation) are out of scope for this change.
Nullstate-Studio
pushed a commit
that referenced
this pull request
Jul 15, 2026
…_id signature churn Two independent bugs evicted the cached gateway AIAgent on every turn, preventing the prompt cache from ever warming: 1. Model normalization mismatch: the post-run fallback-eviction check compared _agent.model (stripped in AIAgent.__init__) against the raw _resolve_gateway_model() config string. For vendor-prefixed config on native providers (e.g. 'deepseek/deepseek-v4-pro' vs 'deepseek-v4-pro') this was always unequal, so the agent was evicted after every successful run. Normalize _cfg_model the same way (skip aggregators). 2. Discord triggering message_id leaked into the cached system prompt via build_session_context_prompt()'s Discord IDs block. message_id changes every turn, so the agent-cache signature (computed from the ephemeral prompt) changed every Discord turn -> rebuild every message. The id is now injected per-turn into the user message (where per-turn content belongs and does not touch the cache signature); the cached IDs block carries a static pointer to it, preserving reply/react/pin via the discord tools. Adapted from NousResearch#28846. Bug #1 fix is the contributor's; bug #2 reworked to be non-destructive (keeps the triggering-id capability instead of deleting it). Redundant auto-reset eviction (already on main via NousResearch#9893/NousResearch#48031) and the wrong-premise reset_context_note plumbing from the original PR were dropped. Co-authored-by: Hermes Agent <hermes@nousresearch.com>
Nullstate-Studio
pushed a commit
that referenced
this pull request
Jul 15, 2026
… fail on '(empty)' sentinel Two related bugs caused subagent delegation to silently return empty summaries with 0 tokens when the user configured delegation.provider=bedrock alongside delegation.base_url=https://bedrock-runtime.<region>.amazonaws.com. Root cause #1 — misrouting in _resolve_delegation_credentials(): The configured_base_url branch unconditionally forced provider='custom' and api_mode='chat_completions', only specializing for chatgpt.com, anthropic, and kimi hosts. Bedrock (and other native-SDK providers) fell through as 'custom' + chat_completions, which then POSTed OpenAI-shaped JSON at Bedrock's native API. Bedrock rejected the payload and returned nothing, which looked like an empty LLM response to the child agent. Fix: when provider is one of {bedrock, vertex, google, google-genai}, skip the base_url short-circuit and fall through to resolve_runtime_provider(), which knows how to construct the proper SDK client. base_url can still be forwarded through that path for regional overrides. Root cause #2 — '(empty)' sentinel accepted as success: After N retries of empty LLM responses, run_agent.py emits the literal string '(empty)' as final_response. _run_single_child then hit `elif summary:` — '(empty)' is truthy, so status became 'completed' and the parent surfaced a blank result with no error. Users saw api_calls=4, tokens=0, duration~0.4s, status=completed. Fix: treat final_response.strip() == '(empty)' as a failure so the parent surfaces it instead of silently accepting zero-content 'success'. Both paths were reproduced in a live Hermes TUI session on us-west-2 Bedrock (provider=bedrock, model=us.anthropic.claude-sonnet-4-6) and are covered by new tests in tests/tools/test_delegate.py.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(desktop): refresh session list after delete/archive, extract sessionMatchesStoredId
The post-delete server refresh was removed in 7a2d498; this restores it with
the new listAllProfileSessions API, scoped to the deleted session's profile.
Also extracted sessionMatchesStoredId from use-session-actions.ts into
store/session.ts alongside sessionPinId — the same lineage-aware matching
was inlined in desktop-controller.tsx. Centralizing it keeps all callers
in sync and prevents the logic from diverging again.