fix(dashboard): include X-Forwarded-Prefix in _safe_next_target() next= redirect#3
Open
grunjol wants to merge 26 commits into
Open
Conversation
The hardened echo ring (NousResearch#31250) changes _recent_sent_timestamps from a set to an OrderedDict, so the reply-detection-cache regression test from the quote salvage can no longer call .discard(); route it through the new _consume_sent_timestamp() helper, which is the real echo-removal path.
…l-echo-ring fix(signal): salvage echo-ring LRU+TTL hardening (NousResearch#31250)
`_sent_message_timestamps` (the reply-to-own-message quote cache) used a `set` evicted with `set.pop()`, which removes an ARBITRARY element — so once more than the cap (500) outbound timestamps are tracked, a still-recent timestamp could be dropped while older ones survive, missing a genuine reply-to-own-message. Convert it to an OrderedDict with FIFO (oldest-first) eviction, mirroring the recently-hardened echo ring (NousResearch#31250). This closes the same bug class on the sibling cache. Adds a regression test asserting oldest-first eviction + MRU promotion.
…ote-cache-eviction fix(signal): FIFO-evict the quote-detection timestamp cache (follow-up to NousResearch#49678)
…gram/wecom/email/sms adapters to bundled plugins Salvage of PR NousResearch#41284 onto current main. Relocates the last 9 inline messaging adapters (+ satellites: telegram_network, feishu_comment/_rules/meeting_invite, wecom_crypto, wecom_callback) from gateway/platforms/ into self-contained bundled plugins under plugins/platforms/<x>/, discovered via the platform registry. Strips the per-platform core touchpoints from gateway/run.py, gateway/config.py, hermes_cli/gateway.py, hermes_cli/setup.py, and tools/send_message_tool.py. Carries forward the migration fixes (explicit enabled:false honored, get_connected_platforms forces discovery, plugin is_connected via gateway.get_env_value, logs --component gateway matches plugins.platforms.*, matrix hidden on Windows). Additionally ports config keys main added since the PR base: the matrix plugin's _apply_yaml_config now also covers allowed_users, ignore_user_patterns, process_notices, and session_scope (the inline gateway/config.py matrix block gained these in the 1340 commits the PR sat open; they would otherwise have been silently dropped on deletion).
test_telegram_webhook_secret reads telegram adapter source by path; point it at plugins/platforms/telegram/adapter.py. test_windows_native_support npm-spawn parametrization referenced gateway/platforms/whatsapp.py; point it at plugins/platforms/whatsapp/adapter.py.
…rch#49697 Re-indent the salvaged title= lines to spaces (prettier), and map alelpoan@proton.me in the release author map.
…hing (NousResearch#36733) * feat(setup): Blank Slate setup mode — minimal agent, opt in to everything Adds a third first-time setup option alongside Quick Setup and Full Setup. Blank Slate forces ON only what an agent needs to run — provider & model, the File Operations toolset, and the Terminal toolset — and turns everything else OFF, then walks the user through opting each capability back in. What it does: - platform_toolsets.cli = [file, terminal] (explicit, authoritative list) - agent.disabled_toolsets = every other known toolset (web, browser, code_execution, vision, memory, delegation, cronjob, skills, image_gen, kanban, …). Applied last in the resolver, so it overrides the non-configurable platform-toolset recovery that would otherwise re-add toolsets like kanban — guaranteeing a true blank slate. - Optional config features off: compression, memory + user-profile capture, checkpoints, smart model routing, auto session reset. - Bundled skills default to NONE (reuses the .no-bundled-skills marker); offers to seed the full catalog. - Walks through tools / plugins / MCP / messaging, all opt-in. Proven end-to-end: with the Blank Slate config, model_tools.get_tool_definitions emits exactly 6 schemas — patch, process, read_file, search_files, terminal, write_file. Nothing else reaches the model. Re-enable later via hermes tools / hermes skills opt-in --sync / hermes setup agent. Tests: tests/hermes_cli/test_setup_blank_slate.py (8 tests) pin the writers, the resolver invariant ({file, terminal}), and the 6-schema end-to-end set. Docs: getting-started/quickstart.md documents all three setup modes. * feat(setup): Blank Slate fork — finish minimal, or walk through configs After applying the minimal baseline (provider/model + file + terminal, everything else off), Blank Slate now presents a choice instead of always running the full walkthrough: 1. Start with everything disabled — finish now with the minimal agent. 2. Walk through all configurations — opt in to tools, skills, plugins, MCP, and messaging. Provider/model and terminal are still configured first either way (the agent can't run without them). The finish-now path records the bundled-skill opt-out so future `hermes update` runs don't re-inject skills. The walkthrough body moved to a separate _blank_slate_walkthrough() helper. Tests: TestBlankSlateFork covers both branches (finish-now applies baseline + skill opt-out and skips the walkthrough; walkthrough path invokes it). Docs updated to describe the fork.
… failover The session-stable system prompt embeds Model:/Provider: identity lines, but mid-turn failover (try_activate_fallback) swaps the runtime without touching them, so a fallback model misreports itself as the primary when asked "what model are you?". rewrite_prompt_model_identity() rewrites the last occurrence of each line on _cached_system_prompt when a fallback activates (and back on restore, byte-identical so the primary's prefix cache still hits). The rewrite is never persisted to the session DB. _sync_failover_system_message() patches the in-flight api_messages[0] at all 8 failover sites so the current turn ships the corrected identity. Cache-safe: the fallback's prefix cache is cold on a model switch anyway. Co-authored-by: Hermes Agent <noreply@nousresearch.com>
Author email lacked a numeric-id prefix so the noreply auto-extraction misses it; map it explicitly for PR NousResearch#43872 salvage.
…aining it A nous inference_base_url that fails the host allowlist (e.g. a stale stg-inference-api.nousresearch.com persisted before the allowlist existed) was only replaced 'if refreshed_url:' — so when the validator rejected the URL it left the poisoned value in place. The 'falling back to default' warning fired but never took effect: every subsequent call, including the auxiliary compression call, kept hitting the dead staging endpoint and 401'd. Reset to DEFAULT_NOUS_INFERENCE_URL when validation returns None at both refresh sites in resolve_nous_runtime_credentials, so a poisoned auth.json self-heals on the next refresh. The proxy adapter already did this correctly; this brings the two auth.py sites in line.
…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 NousResearch#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.
…dent end-to-end Review (Codex + 3-agent parallel) found the first cut of in-place mode was incomplete: it only updated the system prompt, so the persisted transcript stayed 'full history + summary' and the next turn/resume reloaded the full history and immediately re-compacted (a loop), and every downstream layer that keyed off session-id rotation silently no-op'd. The session_id was doing double duty as the 'compaction happened' signal. This wires the whole path so removing rotation is actually complete: Agent (agent/conversation_compression.py): - In-place now DURABLY replaces the transcript: replace_messages(session_id, compressed) on the same row (the canonical store the gateway reloads from), not just update_system_prompt. Resume reloads the compacted set; no loop. - Reset flush identity/cursor (_last_flushed_db_idx=0, _flushed_db_message_ids cleared) so next-turn appends diff against the compacted transcript. - Expose a rotation-independent signal: agent._last_compaction_in_place, and in_place=True on the session:compress event. - Fire the compaction-boundary hooks (context-engine on_session_start, memory manager on_session_switch, reason='compression') in BOTH modes — in-place passes the same id as parent so DAG/buffer state still checkpoints. Without this, memory/context plugins miss every in-place compaction. Gateway auto-compress (gateway/run.py): - Read agent._last_compaction_in_place; set history_offset=0 on rotation OR in-place (both return the compacted set, so slicing past the pre-compaction length would drop everything). Carry compacted_in_place in the result dict. - No extra rewrite needed: the agent shares the gateway's SessionDB, so its replace_messages already updated the canonical store load_transcript reads. Manual /compress (gateway/slash_commands.py): - The throwaway /compress agent has no _session_db, so rewrite_transcript is the durable write. Previously gated behind 'if rotated:' which treated 'id unchanged' as the NousResearch#44794 data-loss failure case and SKIPPED the rewrite — making /compress a silent no-op in in-place mode. Now rewrites on rotated OR in_place; the data-loss guard still fires only for the genuine no-rotation-AND-not-in-place failure. Hygiene auto-compress already writes _compressed to the same id unconditionally (its agent has no _session_db, can't rotate) — correct for in-place, no change. Tests (tests/run_agent/test_in_place_compaction.py): - Assert the DURABLE transcript IS the compacted set after reload (get_messages_as_conversation == compacted), message_count==2, flush identity reset, and the rotation-independent signal set on in-place / unset on rotation. Rotation regression guard unchanged. Verified: 64 tests green across in-place + rotation/persistence/boundary/ concurrent/failure-sync/command/cli suites; E2E both modes (durable replace, gateway offset=0, rotation preserves old transcript); ruff clean. Still default-off.
Parallel 3-reviewer cleanup of the in-place compaction code. Findings applied:
- perf: in-place mode no longer pre-flushes current-turn messages. The flush
ran INSERTs that the immediately-following replace_messages(compressed)
DELETE+reinsert discarded -- pure wasted writes per compaction. The
current-turn tail survives via the compressor's compressed output
(protect_last_n), not the flush. Verified no data loss; rotation still
pre-flushes (its old session row is preserved, so the flush is real there).
- quality: hoist the two shared post-write steps (update_system_prompt +
_last_flushed_db_idx = 0) below the if/else -- they ran in both branches
against agent.session_id. Removes the easiest divergence bug.
- quality: compute the compaction-boundary locals (_old_sid, _is_boundary,
_boundary_parent) ONCE instead of recomputing locals().get('old_session_id')
and the "_old_sid or agent.session_id or ''" chain three times.
- quality: initialize compacted_in_place up front and assign
agent._last_compaction_in_place directly, dropping the fragile
locals().get('compacted_in_place') reflection.
- reuse: parse the in_place config flag with utils.is_truthy_value (the
project's canonical truthy coerce) instead of a hand-rolled
str().lower() in {...} (agent_init already imports from utils).
Dropped as false positives / out of scope: gateway getattr of agent internals
(established session_id pattern), dual result-dict carry (mirrors history_offset
etc.), stringly-typed "compression" (codebase-wide convention, no constant).
Behavior-preserving: 7 in-place tests (incl. 2 new flush-guard tests) + 26
rotation/boundary/persistence/command tests green; mutation check confirms the
durable-replace guard still binds (removing replace_messages fails the test);
ruff clean. Added test_in_place_skips_redundant_preflush /
test_rotation_still_preflushes to guard the perf change.
…e, not delete) Teknium review: keeping one durable session id must NOT come at the cost of destroying history. The prior in-place implementation used replace_messages, which hard-DELETEs the pre-compaction turns (they also drop out of the FTS index) — same id, but the original conversation is gone with no recovery path and the summary becomes the only record. Rotation today is non-destructive (the old session's full transcript survives under the old id); in-place must match that durability contract, not weaken it. Fix: compact in place by SOFT-ARCHIVING, reusing the existing messages.active flag (the /undo soft-delete mechanic), instead of deleting: - New SessionDB.archive_and_compact(session_id, compacted): in one atomic write, UPDATE messages SET active=0 on the live turns, then insert the compacted set as fresh active=1 rows. Nothing is deleted. - The insert loop is extracted into a shared _insert_message_rows() helper so archive_and_compact and replace_messages don't duplicate the 60-line column/encoding block (extend-don't-duplicate). - Agent in-place branch calls archive_and_compact instead of replace_messages. Durability outcome (proven by test + E2E across repeated compactions): - Live context load (get_messages_as_conversation / get_messages) filters active=1, so a resume reloads ONLY the compacted set — compaction still shrinks the live session. - The pre-compaction turns stay on disk at active=0, recoverable via get_messages(include_inactive=True) / restore_rewound. - They remain FTS-searchable: the messages_fts* triggers index on INSERT and remove on DELETE only — they do NOT key on active, and active=0 is a content-preserving UPDATE. session_search still finds them. - Verified across TWO successive compactions: the 1st compaction's originals are still recoverable + searchable after the 2nd (answers the "no recovery path after the next compaction" concern directly). message_count now reflects the LIVE (active/compacted) count, matching the live load. replace_messages keeps its DELETE semantics (still correct for /retry, /undo) and gains a docstring note pointing compaction at the non-destructive method. Tests: test_in_place_keeps_same_session_id strengthened to assert the 8 seeded originals survive at active=0 alongside the 2 compacted rows AND stay FTS-searchable. Mutation check: swapping archive_and_compact back to a hard DELETE fails the test, so the non-destructive contract is bound. 285 hermes_state + in-place tests green; rotation/persistence/compress-command/cli suites green; ruff clean.
…ion_search Follow-up to the soft-archive durability fix. Reusing the rewind/undo active=0 flag for compaction-archived turns inherited the wrong search semantics: undo rows are intentionally HIDDEN from session_search (the user took them back), but compaction-archived turns must stay DISCOVERABLE — that is the whole point of Teknium's "searchable / recoverable" requirement. As built, search_messages defaulted to WHERE active=1, so after in-place compaction the pre-compaction turns were in the FTS index but filtered out of the default search. (The earlier "searchable" claim only held for a raw FTS query / include_inactive=True, not the actual session_search tool.) Empirically confirmed the gap: search 'HMAC' returned 2 hits before compaction, 1 after (only the summary's mention) — the originals were hidden. Fix — a `compacted` flag distinct from `active`, giving a 3-way state: - active=1, compacted=0 → live context (normal) - active=0, compacted=1 → compaction-archived: OUT of live context, IN search - active=0, compacted=0 → rewind/undo: OUT of live context, OUT of search Changes: - messages.compacted INTEGER NOT NULL DEFAULT 0 added to SCHEMA_SQL. Declarative _reconcile_columns adds it on existing DBs — no version bump (plain column add). - archive_and_compact: UPDATE … SET active=0, compacted=1 (was active=0 only). - search_messages: default WHERE active=1 → (active=1 OR compacted=1), on BOTH the main FTS5 path and the trigram CJK path. include_inactive=True still returns everything. The short-CJK LIKE fallback already returns all rows (no active filter) — unchanged. - Docstrings on archive_and_compact + search_messages document the 3-way state. Verified: after compaction, session_search default finds the archived originals (ids 1 & 4); rewind/undo rows stay hidden by default (recoverable via include_inactive); live context still excludes both. 322 in-place + hermes_state tests and 46 session_search tests green; ruff clean. Mutation check: reverting the search WHERE to active-only fails the new searchable test. (Surfaced by the question "is search semantic or only FTS?" — answer: session search is FTS5 keyword/BM25 only, no embeddings over the transcript; semantic retrieval lives in the optional memory-provider layer. Tracing that confirmed the active-only filter gap above.)
Review nit (yoniebans): the config.py comment still said compaction is 'lossy: the pre-compaction transcript is discarded, matching Claude Code / Codex' — leftover from the original destructive design. The shipped behavior is soft-archive: lossy for the LIVE context (what the model reloads), but the pre-compaction turns are kept on disk (active=0, compacted=1), searchable via session_search and recoverable. Comment now says so. Comment-only; no behavior change.
…ion event (NousResearch#49738) Async-delegation completions (delegate_task(background=true)) and background-process completions (terminal notify_on_complete) re-enter the originating session as internal MessageEvents. When the session was busy, _handle_active_session_busy_message treated them like a user TEXT message and the default busy_input_mode='interrupt' aborted the active turn (and sent a 'Interrupting current task' ack) — the opposite of the design invariant that a completion surfaces as a new turn only when idle. Short-circuit internal events to return False so the base adapter queues them silently (it already excludes internal events from debounce), cascading them as the next turn after the current one finishes.
…idated return (NousResearch#49734) * feat(delegation): single-task delegate_task always runs in the background The model no longer decides whether a subagent runs in the background — a single-task delegate_task from the top-level agent is now always dispatched async, so the parent turn returns immediately and the subagent's result re-enters the conversation when it finishes. - run_agent._dispatch_delegate_task (the live model path) forces background=True for top-level single-task calls; the schema-level `background` param is ignored. - A batch (tasks with >1 item) stays synchronous (fan-out can't go async). - A delegation from an orchestrator subagent (depth > 0) stays synchronous — it needs its workers' results within its own turn. - The function-level default is unchanged, so direct Python callers/tests keep the historical synchronous behavior. - On async-pool capacity rejection, single-task now falls through to a synchronous run instead of erroring (the child stays attached for interrupt propagation; detach happens only on a successful dispatch). - Schema `background` param marked deprecated/ignored; tool description updated to state the always-background single-task rule. * feat(delegation): all delegate_task fan-out runs in the background Extend the always-background behavior to the full fan-out. A batch is now dispatched as N independent async subagents (one handle each), instead of running synchronously. Single task and batch both return immediately; each subagent's result re-enters the conversation as its own message when it finishes. - delegate_task: when background is set, loop over ALL built children and dispatch each via dispatch_async_delegation; return a combined handle block (count + per-task delegation_ids). Children the async pool rejects (at capacity) run synchronously inline and are reported alongside the dispatched handles, so nothing is silently dropped. - run_agent._dispatch_delegate_task + registry handler: force background for any top-level model delegation (single OR batch); orchestrator subagents (depth > 0) still run synchronously since they need workers' results within their own turn. - Removed the v1 'batch async not supported' rejection. - Tool description updated: BOTH MODES RUN IN THE BACKGROUND. - Tests updated to assert batch fan-out dispatches each task async (verified E2E: 3-task batch -> 3 independent completion-queue events). * fix(delegation): background fan-out joins and returns one consolidated block Correct the fan-out semantics: a backgrounded batch is dispatched as ONE async unit (one handle, one async-pool slot), not N independent dispatches. The unit runs all children in parallel, waits on every one, and emits a SINGLE completion event carrying the consolidated per-task results. The chat is never blocked; when all subagents finish, their full summaries re-enter the conversation together as one message. - async_delegation.dispatch_async_delegation_batch + _finalize_batch: a batch occupies one slot; its runner returns the combined {results:[...]} dict and one event with the full results list is pushed to the completion queue. - delegate_tool: extract the sync execution+aggregation into _execute_and_aggregate(); background dispatches it via the batch unit and returns one handle; on pool-capacity rejection it runs the batch inline. - process_registry._format_async_delegation: render a consolidated multi-task block (TASK i/N + per-task summary) when the event carries is_batch/results. - Tests updated; E2E verified: 3-task batch -> immediate return -> one combined completion block with all three summaries.
… (401/403)
When the active provider returns a 401/403 that survives its per-provider
credential-refresh attempt (revoked OAuth, blocked/expired key, or an
account pinned to a dead/staging inference endpoint), the conversation
loop now escalates to the configured fallback chain instead of dead-ending.
Before: the generic failover dispatch fired only for {rate_limit, billing};
auth/auth_permanent fell through to 'switch providers manually' advice and
never called _try_activate_fallback(). A user whose primary credential was
broken kept thrashing on the same dead credential every turn — the main
agent appeared 'stuck in fallback mode' while never actually failing over.
This also affected auxiliary tasks (compression, vision, title-gen), since
auto-resolved aux follows the main provider.
After: a persistent auth failure with a configured fallback chain switches
to the next provider (mirroring the rate-limit/billing failover path),
guarded one-shot per attempt by TurnRetryState.auth_failover_attempted.
When no fallback is configured the behavior is unchanged — it falls through
to the existing terminal handling and provider-specific troubleshooting
guidance.
Tests: test_auth_provider_failover.py — 401/403 classify as auth, the
gating condition fires only with a chain present + guard unset, the guard
blocks repeats, and non-auth (500) errors do not trigger auth failover.
…graded session When the auxiliary summary call fails with an authentication/permission error (HTTP 401/403), context compression now ABORTS and preserves the session unchanged instead of rotating into a child session with a placeholder summary. Before: a 401 (invalid/blocked key, or a token pointed at the wrong inference host) fell through every transient-error check to 'return None', and because compression.abort_on_summary_failure defaults False, compress() took the static-fallback path and rotated the session anyway (messages N->N). The user landed on a fresh-but-broken session that kept failing the same way — paying for a full-context API call each turn with no useful compression. After: _generate_summary classifies 401/403 as a non-recoverable auth failure (_last_summary_auth_failure) and compress() aborts on it regardless of abort_on_summary_failure. A distinct auxiliary summary_model that 401s still retries once on the main model first (its dedicated creds may be the only broken thing); the abort only sticks when the main model itself auth-fails or the fallback also auth-fails. The existing _last_compress_aborted handling in conversation_compression.py already skips rotation and emits a warning, so no session rotation occurs. Tests: TestAuthFailureAborts — 401/403 flagging, compress() aborts despite flag=False, non-auth failures keep the historical fallback path, and aux-model auth failure recovers on main without aborting.
c83c3d7 to
0ab4dba
Compare
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.
Builds on NousResearch#48625.
Problem
_safe_next_target() builds the next= query parameter for the login URL
(e.g. /login?next=/sessions). When the dashboard is behind a reverse proxy
at a sub-path like /hermes, the returned path is /sessions instead of
/hermes/sessions, so after login the user lands outside the proxy mount.
Changes
bare path before prefix, so detection is not broken by the mount point
prefix, rejection cases
Testing
python -m pytest tests/hermes_cli/test_dashboard_auth_401_reauth.py -x -q
→ 47 passed (7 new)
python -m pytest tests/hermes_cli/test_dashboard_auth_*.py -x -q
→ 249 passed, no regressions