Sync/upstream 2026 08 14 - #30
Merged
Merged
Conversation
…ob creation
A job created from within a cron run must never store the literal
'origin' delivery target: the creating session is ephemeral, so by fire
time there is no origin to resolve and the scheduler falls back to
guessing a home channel. With agent scheduling enabled
(cron.allow_agent_scheduling), a scheduled agent creating follow-up jobs
would silently produce exactly that dangling shape.
Resolve at create time instead, in cron context only: 'origin' elements
(and an omitted deliver) are replaced with the creating run's concrete
target from the per-run HERMES_CRON_AUTO_DELIVER_* contextvars —
platform:chat_id[:thread_id], or 'local' when the creating run has no
concrete target. Explicit values ('local', 'all', platform:chat_id
targets) pass through verbatim, including inside comma lists. Chat and
CLI creates are byte-identical to before: the resolver is a no-op
outside cron-context sessions (HERMES_CRON_SESSION unset).
Review caught a real gap: action='update' also accepts deliver, and the tool description explicitly steers agents toward update-over-create — so a cron-context agent updating a job to deliver='origin' would recreate exactly the dangling literal-origin shape the create-path resolution prevents (stored 'origin' on an origin-less job → fire-time home-channel guessing or silent drop). Wrap the update site in the same resolver. Semantics follow the create precedent: in cron context, 'origin' means 'my run's target', resolved concretely at mutation time; outside cron context updates are byte-identical to before.
) * feat: server-side ui_meta on profiles.list/configure Roster UIs built on profiles.* have per-profile presentation state (avatar, accent color, display title, pet) with nowhere server-side to live — client plugin storage paints a different roster on every machine. profiles.configure now accepts ui_meta (merged key-wise into profile.yaml's ui_meta block via the existing atomic_yaml_write path, null deletes a key, 64KB cap since it rides every roster paint) and profiles.list returns the block per row. Consumers namespace under their own key. No new files or config; profiles without the block are unchanged. * test: stop primary-runtime-restore tests probing live endpoints _make_agent left the compressor's lazy context-length resolution unmocked; for reachable base_urls (the nous portal test) the endpoint's 32K answer for the empty test model trips agent_init's 64K floor and fails the suite on network behavior. Pin get_model_context_length in the fixture.
…S sends voice.toggle (status) triggers check_voice_requirements() -> STT provider auto-detect -> a synchronous faster-whisper lazy install (uv/pip subprocess with a 300s timeout). Inline on the WS reader thread it stalls handle_ws before it reads the next frame, so prompt.submit / session.list queued behind a voice.toggle sit unread and the desktop 'send message' appears dead for minutes while the install churns (reproduced: voice.toggle -> session.list 40s+ timeout). Route voice.toggle/voice.record/voice.tts to the RPC pool (same bug class as NousResearch#21123 / NousResearch#50005) so a slow lazy install can't block message handling. Adapt the voice handler tests to drive the handler inline via a small _dispatch_sync helper (preserving transport-binding semantics) since dispatch() now returns None for pool-routed methods, and add a regression test asserting the voice RPCs stay pool-routed.
…ll chain
wake.start calls check_wake_word_requirements() → _stt_ready() →
_get_provider() → _try_lazy_install_stt() → ensure("stt.faster_whisper")
(same synchronous subprocess install chain as voice.toggle), and
start_listening() → _build_engine() whose constructors call
lazy_deps.ensure("wake.openwakeword" / "wake.sherpa" / …).
wake.status calls check_wake_word_requirements() too and is polled
by the desktop on every gateway-ready. Same bug class as NousResearch#21123 /
NousResearch#50005 — sibling to the voice RPC fix in the prior commit.
Update existing wake.start test call sites from server.dispatch() to
_dispatch_sync() since dispatch() now returns None for pool-routed
methods. Extend the pool-routing regression test to cover wake RPCs.
Bare noreply form (no +<id> prefix) needs an explicit mapping file for check-attribution CI.
voice.toggle is now pool-routed (returns None from dispatch), so the audio playback guard test must call handle_request directly to get the response dict.
…dpoint catalogs '' is a substring of every catalog key, so _resolve_endpoint_context_length with an empty model name "matched" whatever the endpoint listed first — on the Nous portal that is currently a 32K embedding model, which poisoned the resolved context length and made AIAgent init fail the 64K minimum. This is what turned tests/run_agent/test_primary_runtime_restore.py:: TestTryRecoverPrimaryTransport::test_allowed_for_nous_anthropic_messages red on every PR (CI slice 7/12) after the portal catalog reordered. Single-model endpoints still resolve with an empty name (unambiguous); non-empty names keep the substring fuzzy match.
… live orchestration Companion docs for NousResearch#85232 — adds the model-facing control section (list/steer/stop, ownership scoping, spawn-cap exemption) above the existing TUI/gateway subagent.steer RPC docs.
docs.x.ai (2026-08-12): grok-4.6 is the flagship, 500K context. Live GET /v1/models lists grok-4.6 at context_length 500000 (no grok-4.6-latest alias). NousResearch#84661 landed the catalog. Main already lists native grok-4.6 on the xAI picker. This is only the leftover cache guard (same pattern as grok-4.3): pre-catalog builds persisted the grok-4 catch-all (256K).
Replaces the per-model _model_name_suggests_grok_4_3/_grok_4_6/ _minimax_m3 stale-cache predicates with one generic _stale_pre_catalog_cache_entry() guard driven by _PRE_CATALOG_STALE_KEYS. A cached context length is dropped when the model resolves (longest-key-first, same as step 8) to a listed catalog key and the cached value is at or below what the old resolution path could have produced (largest shorter matching catch-all, or the 256K fallback). Also covers qwen3.6-plus, grok-4-fast, and grok-4.20 (the models PR NousResearch#37684 requested guards for), absorbing that PR. _model_name_suggests_minimax_m3 is kept for its two non-cache callers (models.dev underreport guard, cache-control gating in agent_runtime_helpers).
Slack Web API calls return `SlackResponse`/`AsyncSlackResponse`, which are
mapping-like but not `dict` subclasses, so every `isinstance(resp, dict)`
gate took its "unexpected shape" branch at runtime: user and channel names
collapsed to raw IDs, every user resolved as a non-bot (defeating the
allow_bots loop guard), ephemeral replies were reported as failures, and
uploads/caption fallbacks lost their message_id.
Normalize responses through a single `_slack_response_payload()` helper
(dict passes through, SDK response yields `.data`, anything else yields
`{}` so callers keep their fallbacks) and use it at every call site.
Existing Slack tests injected plain dicts, which is why the defect was
invisible; the new tests run each behavioral case against a real
`AsyncSlackResponse` as well.
…se reads Review on NousResearch#74658 flagged that the response-shape suite exercised identity, ephemeral and upload paths but left two changed call sites untested: - create_handoff_thread's seed-message ts (adapter.py:2262), which anchors every subsequent handoff send onto the thread; - the standalone media branch's chat_postMessage reads (adapter.py:8721 text post, :8749 caption fallback), where an SDK-shaped reply used to drop the ts and report a caption-only delivery as 'nothing deliverable'. Both new cases run against the hand-rolled stand-in and the real AsyncSlackResponse. Verified they fail against the pre-fix adapter. Co-authored-by: Junie <junie@jetbrains.com>
…m endpoints The custom + explicit_base_url branch of resolve_provider_client() unconditionally rewrote a trailing /anthropic to /v1 via _to_openai_base_url(), even when api_mode was anthropic_messages. The Anthropic wrapper then never saw the real /anthropic path, so auxiliary tasks (title generation, compression, vision, web_extract, session_search) hit .../v1/chat/completions on a Messages-only endpoint and failed. Guard the wrap base on api_mode: for anthropic_messages, pass the raw /anthropic base to _wrap_if_needed (which builds the Anthropic wrapper), while the plain OpenAI client keeps the /v1-rewritten base so the OpenAI-wire fallback (used when the anthropic SDK is unavailable) never lands on /anthropic/chat/completions. Refs NousResearch#16254
… fan-out Salvaged from PR NousResearch#83437 by @erosika, with adopted fixes from @bgodlin (NousResearch#81054), @aldoeliacim (NousResearch#82332), @nftpoetrist (NousResearch#42326), @rodboev (NousResearch#39653), @FnExpress (NousResearch#64292, supersedes NousResearch#32175 by @db-aeon), @Per0-1 (NousResearch#61166), @NaMinhyeok (NousResearch#64797), and @liuhao1024 (NousResearch#43130). Widens the bundled Langfuse plugin from 6 to 11 hooks and fixes two attribution bugs. Also adopts shutdown/atexit lifecycle fixes and composes 8 prior community PRs with interaction-fix follow-ups. Model attribution: on_pre_llm_request and on_post_llm_call now prefer the wire value (request body model, response model) over the agent attribute, which goes stale after /model switch or provider fallback. Cost total: both cost paths now send a summed total alongside the per-type breakdown, since Langfuse does not derive calculatedTotalCost from cost_details keys. Subscription-included routes send no cost keys at all. New coverage: api_request_error closes failed generations with ERROR level; on_session_finalize/on_session_end close dangling traces for tool-only and interrupted turns; subagent_start/subagent_stop trace delegated children as spans; MoA advisor fan-out emits one generation per advisor priced at the advisor's own model. Capture modes: HERMES_LANGFUSE_CAPTURE=metadata|sanitized|full (default sanitized). Sanitized mode redacts secret patterns before truncation. Adopted lifecycle fixes: shutdown client at session finalize when reason=shutdown (not on session rotation); atexit finalizer ends open root spans for short-lived processes; root context manager exited to prevent interpreter-teardown TypeError; TOCTOU on _get_langfuse() fixed with lock; reasoning_content surfaced in traces; system prompt included in generation input for Anthropic/Codex/Bedrock; SDK v3 update_trace replaces set_trace_io. Closes NousResearch#29482, NousResearch#43129, NousResearch#72661. Supersedes NousResearch#81054, NousResearch#82332, NousResearch#42326, NousResearch#39653, NousResearch#64292, NousResearch#32175, NousResearch#61166, NousResearch#64797, NousResearch#43130. Partially addresses NousResearch#67544 (capture modes + secret redaction; user_id remains open).
Follow-up fixes from /hermes-pr-review + /simplify-code on PR NousResearch#83437: 1. Replace _redact_secrets with agent.redact.redact_sensitive_text(force=True) — the plugin's 11-pattern list was a strict subset of the 50+ patterns in agent/redact.py. Secrets like Stripe keys, Google API keys, GitLab tokens, HuggingFace tokens, DB connection strings, and Telegram bot tokens would all leak through the plugin's list but are caught by the existing redactor. Added pk-lf- (Langfuse public key) to _PREFIX_PATTERNS in agent/redact.py. 2. Remove dead 'not isinstance(client, object)' check in on_session_finalize — always False for any Python value. 3. Fix MoAClient.last_reference_metrics() to call the public self.chat.completions.last_reference_metrics() instead of reaching into the private _last_reference_metrics attribute via getattr. 4. Deduplicate _coerce_request_messages call in on_pre_llm_request — pass pre_coerced=input_messages to _messages_for_langfuse_input to avoid double-coercion + double _capture_content serialization per API request. 5. Add HERMES_LANGFUSE_CAPTURE to OPTIONAL_ENV_VARS in hermes_cli/config.py for consistency with the other HERMES_LANGFUSE_* env vars. 6. Fix test_sanitized_mode_redacts_secrets test data — the old samples ('sk-abc...1234', 'sk-ant...1234', 'Authorization: Bearer ***') were too short to match the regex thresholds and never actually tested redaction. Updated to realistic-length secrets and changed assertions to check that the output differs from input (redact_sensitive_text masks rather than inserting the literal string 'REDACTED').
…loop.py The diff-apply salvage introduced stale-base revert hunks — the PR was 1246 commits behind main, and its diff for conversation_loop.py and moa_loop.py silently dropped symbols added after the PR's base (e.g. _CODEX_ACK_CONTINUATION_NUDGE, _INTERRUPT_SCAFFOLD_MARKER, cache_ttl plumbing, finalize_turn import, _restore_user_after_reference_handoff). Restored both files to origin/main and re-applied only the PR's additive changes: _moa_reference_metrics_for_hook, _system_prompt_for_hooks, the system_prompt= and moa_references= hook kwargs, _last_reference_metrics attribute and accessors, and the slot_metrics population in the fan-out path. Fixes CI ImportError: cannot import name '_CODEX_ACK_CONTINUATION_NUDGE' from 'agent.conversation_loop'.
Continuous gateway sessions keep the Relay session scope open for days; close-driven export means the session root span and out-of-turn marks never export until /new or idle-end, and a crash loses the open segment entirely. Opt-in segmentation (both defaults OFF => scope lifecycle byte-identical to today): gateway.telemetry.session_segments.on_compaction: false gateway.telemetry.session_segments.max_turns: 0 Rotation closes the current session scope and pushes the next segment (same session_id attribute, plus hermes.session.segment=N and segment_reason=compaction|max_turns) ONLY at a turn boundary in begin_turn — never mid-turn (scope stack is LIFO). Compaction completion just flags rotate_pending (observer semantics, nothing on the compaction critical path); legacy rotating compaction closes the orphaned old session scope so its segment exports. Both native calls ride the existing bounded scope-op executor: a wedged rotation costs one segment span, never the agent. Segment bookkeeping advances even on native failure so a degraded rotation cannot retry every turn.
notify_session_compacted closed the old session scope immediately on a legacy rotating compaction. A compaction can complete while a turn is still live on the old session; closing then pops the session scope under the live turn scope, violating the stack's LIFO order — the exact invariant the rest of the segmentation feature protects. Now: when the old session has an active turn, set close_pending instead; that turn's end_turn consumes the flag after its own turn scope pops and it unregisters from the active-turn table. Sabotage-verified: the new test fails without the fix.
Adds an observability/nemo_relay section to the built-in plugins page (the plugin had no section despite appearing in the shipped table) with the gateway.telemetry.session_segments keys, defaults-off contract, and segment metadata; mirrors a summary in the plugin README.
…ource, setup templates, memory indicators, error hints Bundles previously-separate Hindsight/memory PRs into a single review surface: - opt-in synchronous recall (recall_sync) — recall the injected memory in-turn instead of next-turn prefetch (NousResearch#5820) - actionable error when local_embedded runtime is missing — tells the user which package to install (NousResearch#7718) - default retain_source to 'hermes' so every stored memory self-identifies its provenance - offer a starter memory template during hermes memory setup, plus warn before overwriting an already-configured bank - warn when a configured memory provider reports unavailable (NousResearch#2765) - deterministic 'recalled N memories' recall indicator — Hermes itself emits a status line when auto-recall injects memory - 'saving to memory' retain indicator — emitted the moment a turn is dispatched to the writer Authored by @benfrank241 (ben.bartholomew@vectorize.io). Salvaged from PR NousResearch#74379.
…74379 1. Use open_credentialed_url() instead of bare urlopen() in templates.py apply_template() and probe_existing_customization(). Both send Authorization: Bearer headers; bare urlopen forwards credentials on cross-origin redirects. The codebase has open_credentialed_url() in hermes_cli/urllib_security.py that strips credentials on cross-origin redirects — used by 4 other modules. 2. Guard unavailable_reason() with the dedup set check before calling it. The gateway builds a fresh AIAgent per message, so without this guard unavailable_reason() (which calls _load_config() → stat + file read + JSON parse, and _check_local_runtime() → importlib probes) runs on every gateway turn for an unavailable provider, even though the warning is deduped after the first. 3. Move INDICATOR_GLYPH from Hindsight's eye emoji to a generic brain (🧠) in core (agent/memory_provider.py). Hindsight overrides with its own _HINDSIGHT_GLYPH (👁️) in recall_status() and _emit_saving_indicator(). Other memory providers no longer inherit Hindsight's brand mark as the default glyph.
…apture + preflight fronted-platform blindness
Bug 1: relay-fronted Slack in thread-per-message mode stamps each top-level
message's own id as source.thread_id (session KEYING, native thread_ts
parity). Cron origin capture persisted that stamp as durable routing, so
every delivery landed inside the ephemeral thread spawned around the
creation message instead of the top-level conversation. Fix at the source:
_origin_from_env drops a Slack thread id equal to the creation message's
own id (genuine in-thread creations keep theirs). Fire-time repair for
already-persisted jobs: deliver=origin and the explicit-target Slack
re-attach treat an origin thread as stale when the origin chat is the
configured Slack home chat — top-level (or the home target's configured
thread) wins; non-home working threads are preserved.
Bug 2: _preflight_check_delivery and cron_delivery_targets validated
deliver prefixes against get_connected_platforms(), which only sees
natively configured platforms — a relay-only deployment ({relay}) rejected
'slack:CHAT' with 'no gateway credentials configured' although fire-time
routing (resolve_delivery_transport + RelayAdapter.fronts_platform)
delivers it. New gateway.relay.relay_fronted_platforms() (env-derived from
GATEWAY_RELAY_PLATFORMS — the same source that seeds the live adapter's
identity set, so validation and routing cannot disagree) is unioned into
the connected set when the relay is connected. Native topologies keep the
strict credential check unchanged.
… session thread as the home target Third lane of the same contract (found in live staging validation): /sethome run as a top-level relay-fronted Slack DM message captured the adapter's session-keying thread stamp (the /sethome message's own id) into the persisted HomeChannel.thread_id and its legacy env mirror. Every bare-platform delivery (deliver="slack") then resolved home chat + home thread and landed inside the ephemeral thread around the old /sethome message. Extracted _home_thread_from_source with the same synthetic-stamp recognition as cron origin capture; a /sethome run inside a genuine thread keeps that thread as the home target. Users repair an already-poisoned home target by rerunning /sethome.
…session routing Salvage of NousResearch#37865 by @verybigdog. Adds delivery_mode (notify / notify+wake / wake) on kanban notify subscriptions, persists chat_type + user_id_alt so a woken turn reconstructs the creator's real session key, inherits the return path to child tasks, and keeps wake out of the model-exposed send_message schema. Original commits were authored under a local placeholder identity (hermes-agent@users.noreply.local); re-attributed to the contributor's public email.
…rst migration Before delivery_mode existed the notifier woke unconditionally when the task carried a session_id — pre-existing gateway subscriptions had de facto active wake. The column's 'notify' default alone would silently disable that on upgrade. Backfill gateway rows to notify+wake on first-add only (tui stays notify); explicit user downgrades are never overwritten by re-migration. Sabotage-verified regression tests included.
api_server is stateless — its adapter has no push send(), so the wake self-post IS the delivery on that path. Defaulting those subscriptions to plain 'notify' left them with no delivery mechanism at all (the notifier's doomed send() failed 12 times then dropped the sub), regressing the pre-delivery_mode behavior and failing test_apiserver_sub_wakes_real_session_via_self_post in CI slice 5. Explicit modes still win; other platforms keep the 'notify' default.
An empty/blank model id reaching get_model_context_length() can't be
meaningfully resolved — and it's worse than a miss: the endpoint
metadata fuzzy matcher ('model in key or key in model') is vacuously
true for "", so it matches an ARBITRARY catalog entry from the live
/v1/models response and returns whatever context length that entry
happens to have, persisting it under a junk '@<base_url>' cache key.
This started failing CI on main when the Nous portal catalog changed:
tests/run_agent/test_primary_runtime_restore.py constructs agents with
model='' against the live portal URL, the arbitrary match now lands on
a 32K entry, and init_agent raises the 64K-floor ValueError
(test_allowed_for_nous_anthropic_messages, red on every PR's slice).
Guard early: a blank model id falls back to DEFAULT_FALLBACK_CONTEXT
immediately, before any cache write or network probe.
Salvaged from NousResearch#65515 by @whirmill (rebased onto current main; the
guard now sits after the malformed-base_url normalization added since,
and carries an explanatory comment for the fuzzy-match footgun).
Fixes the red slice on NousResearch#85444, NousResearch#85452 and every other open PR.
Co-authored-by: whirmill <5079591+whirmill@users.noreply.github.com>
…d marker can't wedge the gateway (NousResearch#85433) The NS-570 epoch stamp clears a drain marker that survives a machine restart — but it assumes every drain-gated action ends in a restart. When a maintenance action completes WITHOUT recreating the container and the writer never cancels the drain, the orphaned marker still carries the current epoch, so the 1s drain watcher honours it forever and the gateway bounces every inbound message with the 'draining for a maintenance action' text (observed in the field: a Hermes Cloud instance refused all Telegram turns for ~3 days). The marker already records requested_at; now the readers check it. A marker older than DRAIN_REQUEST_MAX_AGE_SECONDS (1h) reads as stale in drain_requested() and drain_notification_suppressed(), with a loud warning log. Leniency mirrors the epoch check: a missing or unparseable timestamp still reads as drain-active (fail-safe toward quiescing), and a legitimately long drain keeps a sanctioned keep-alive — re-calling write_drain_request() refreshes requested_at. Fixes NousResearch#85433
…d running-output sends The terminal redactor is called without force=True on all three user-facing sends in _run_process_watcher, so process output reaches the platform raw when security.redact_secrets is disabled. The agent-notify path was already covered; this covers the two direct adapter.send() paths the sweeper identified. Review: teknium1 (NousResearch#73547)
…fication (NousResearch#82888) Async-delegation batch completions and background watch notifications re-enter the gateway as synthetic MessageEvent(internal=True) turns via _inject_watch_notification, but were persisted as bare role='user' rows — indistinguishable from real user input in transcripts and the desktop UI. Thread the event's internal flag through to persistence: when event.internal is set, the turn's persisted user row is stamped display_kind='internal_notification' (the existing DB-only presentation sidecar used by auto_continue / model_switch rows). Wired through _run_agent → _run_agent_inner → TurnContext → run_conversation's persist_user_display_kind, and onto the three gateway-side fallback user rows (transient failure, no-new-messages, pre-run crash), whose append_to_transcript writer now forwards display_kind/display_metadata to SessionDB.append_message. Invariants preserved: role stays 'user' (alternation untouched), no new injections, no past-context mutation, and display_kind is already popped from every provider-bound copy in conversation_loop, so replayed sessions never leak the marker to the API. Regression tests: internal turn marked, real user turn unmarked, fallback rows marked/unmarked per event, and a DB round-trip proving replay keeps role/content intact while the provider copy drops the marker.
…e turn (NousResearch#70300) The async-delegation watcher drained the completion queue as a batch but then delivered each event as its own synthetic turn, flooding the session when a fan-out of background subagents finished together. Builds on the per-process completion batching salvaged from PR NousResearch#71898 (thanks @yuzilongleif-collab) which coalesces concurrent _run_process_watcher completions behind a short per-route fan-in window. This commit adds the async-delegation half: group the drained batch by full routing key (session_key + parent_session_id + platform/chat/thread/ user) and inject ONE consolidated turn per group. Durable-ack handling stays honest: sibling rows are claimed up front via claim_event_delivery; rows another consumer owns are excluded from the consolidated text (no double-delivery); sibling claims are acknowledged only after adapter acceptance and released (still pending) on failure. Events for different sessions never coalesce, and a single-event group rides the existing per-event path unchanged (latency and text identical). Tests: 3 same-tick events -> exactly one adapter.handle_message carrying all 3 results with all 3 durable rows delivered; 2 sessions -> 2 turns; single-event path unchanged; failed batch releases claims and retries; foreign-claimed sibling excluded and left pending.
The bare dimmed glyph had nothing to read against. Over a light document in a light theme it is a pale mark on white, and every rest opacity tried (0.35, then 0.45, then 0.75 behind a text halo) came back reported as the button being gone. Give the control its own substrate, which is what every shipped overlay does: Apple's HIG puts controls on a material rather than directly on content, Firefox picture-in-picture draws close/unpip as opaque chips, and Discord's overlay adds a contrast layer over the game. Deriving contrast from the backdrop is not available to us either way -- mix-blend-difference composites against the page, and behind a transparent Electron window that is nothing. The chip now wears the composer bar's own tokens (fill, hairline, radius, bottom shadow), so it inverts with the theme and with the OS appearance under mode 'system'. It rests hidden and fades in while the bar, the band, or the chip itself is hovered, with a hold on the way out so it survives the reach across the gap -- reaching for the HUD is the motion that means "I want the app". Hover rather than focus: the caret gate behind NousResearch#81893 broke the escape hatch exactly when it was needed.
… boundary Plain type=completion events built in _run_process_watcher carried only session_key (chat/thread routing) with no spawning-session stamp, so after /new (or a session switch) a completion notification from the OLD session was injected into the chat's NEW session. Main already solved this exact class for async delegations via the _classify_completion_target pre-flight (_USER_BOUNDARY_END_REASONS drop on user-closed sessions, deliver on idle-ends, follow the compression-tip chain), but the gate only ran for type=async_delegation events. Kernel salvage of NousResearch#16455: - Stamp the spawning conversation's session-db id (HERMES_SESSION_ID via session-scoped env) on the ProcessSession and the pending_watchers entry at spawn time in tools/terminal_tool.py; persist it through the process registry checkpoint/restore so recovered watchers keep the stamp. - Thread the stamp into the completion_evt built by _run_process_watcher (watcher entry first, ProcessSession fallback for recovered watchers). - In _deliver_completion_notification, run the SAME pre-flight classifier for stamped type=completion events: terminal -> drop with a log (output stays available via process(action='log')), retry -> False so the watcher re-polls, deliver -> proceed. The policy has exactly one owner (_classify_completion_target); nothing is forked. Unstamped legacy events keep today's deliver-always behavior, and the async-delegation path is untouched. Based on the session-boundary approach from NousResearch#16455 by @Tosko4 (original PR was over-scoped across adapters/slash-commands/cron; this lands the kernel only). Tests: completion from a /new-closed session is dropped; completion after an idle-end still delivers; unstamped legacy event delivers; retry verdict returns retryable False without adapter injection; async_delegation gate unchanged; stamp survives checkpoint recovery.
…rch#79401) The command path (/model <name> --provider <p>) called _confirm_expensive_model_switch() inline. That modal blocks its calling thread on a response queue (see _prompt_text_input_modal); on the prompt_toolkit main thread the TUI event loop freezes, the modal never renders, and the switch silently cancels after the 120s timeout — the user sees a frozen terminal and 'Model switch cancelled.' without ever seeing the warning. The picker path already dispatched confirm+apply on a worker thread; the command path now mirrors that contract. Extract the inline confirm+apply block into _confirm_and_apply_cli_model_switch() (preserving --once restore and persist semantics) and dispatch it on a daemon thread when a TUI app is present, keeping the synchronous path for non-interactive/test use. Tests: new test_model_switch_confirm_thread.py pins (a) confirm runs off the main thread when _app is present, and (b) the no-app path stays synchronous. Existing _StubCLI helpers forward to the extracted method.
…roviders Custom providers (custom:xxx) serve their own pricing; models.dev stores OpenRouter prices for the same model ids. The cost guard fired on that foreign pricing and blocked composer/CLI model switches on custom providers with a wildly wrong warning (NousResearch#54348). expensive_model_warning now only trusts model_info/models.dev pricing when the provider maps to a models.dev provider and the info's provider_id matches, and only consults the pricing-entry lookup when the billing route is known. Salvaged from NousResearch#54422; the PR's desktop-hook half predates the use-model-controls rewrite and is superseded by the hook's existing rollback handling.
The custom-provider pricing-trust fix makes provider="test" (not a models.dev provider) correctly silent — use anthropic in the fixture.
Run the expensive-model warning for explicit startup `-m` / `--provider` overrides before the chat loop starts, and fail closed for non-interactive invocations that select an expensive or known-confusing model. Also classify Nous paid-model 404s that say credits are required as billing exhaustion so they fail fast with billing guidance. Tested: - scripts/run_tests.sh tests/hermes_cli/test_cli_startup_model_cost_guard.py tests/hermes_cli/test_model_cost_guard.py tests/agent/test_error_classifier.py -- --tb=short -q
…over the light oneshot fast-path Follow-ups on top of the salvaged NousResearch#70324: - _confirm_startup_expensive_model_override evaluates the unified registry (combined_selection_warning) so id-keyed guards like the data-training-tier warning fire at startup too, not just the cost guard. - The Termux-adjacent light oneshot fast-path (added after the PR branched) ran _run_and_exit_oneshot without the guard — same bug class, third sibling site now covered.
…ousResearch#85954) Clicking an agent in a multi-profile roster pays the entire backend spawn + WebSocket dial cost on first open — several seconds of 'loading' (Bot Mode report). Expose the existing pool-only primitive (openGatewayForProfile: opens/pools the socket WITHOUT activating it, already no-ops for the primary and shared-remote routes) as host.warmProfile(name) so rosters can pre-dial after mount and the first click lands on a live socket. Fire-and-forget by design; failures stay silent — the real open path re-runs its own ensure.
…rust and gpt-5.5-pro confusion nudge (NousResearch#85970) 54cc39a (distrust foreign pricing for custom providers) tested with openai/gpt-5.5-pro fixtures; 83d373a (salvaged NousResearch#70324) made that exact id warn unconditionally as a known-confusion model. Each was green alone; together the distrust tests fail on every main run (slice 6). Use a neutral fixture id for the distrust tests and add a regression test pinning the composed behavior: the id-keyed nudge survives custom-provider pricing distrust.
…e/configure (NousResearch#85963) Three widenings for capabilities UIs (Bot Mode's bot builder): 1. profiles.create share_auth (default false): skip the auth.json COPY so the new profile reads OAuth/token state through the existing global-root fallback and refreshes write through to it. A copy forks token state — the first refresh on either side invalidates the other for single-use refresh tokens; sharing keeps ONE live token pool for the main profile and every bot. Static .env keys still copy (no refresh semantics). Receipt: mirrored.auth = 'shared'. 2. profiles.describe reports mcp_servers [{name, enabled, transport}] from the profile's config. 3. profiles.configure accepts enabled_mcp_servers (replace semantics): toggles via the standard disabled flag; enabling a server the profile lacks copies its definition from the launch profile's catalog (names never invented). Launch catalog read BEFORE the home override flips config resolution. E2E: describe keys include mcp_servers; create with share_auth -> mirrored.auth='shared' + no auth.json in the profile dir; configure applied.mcp_servers=true.
Salvage of NousResearch#79604 (webtecnica) + NousResearch#85721 (pierrenode), combined and rebased onto current main with simplify-code findings folded in. NousResearch#79604: update_session_model() wrote the model name to sessions.model but never persisted the provider into model_config. On resume, the runtime recombined the persisted model with the config.yaml primary provider (which may not serve that model), producing auth errors. Fix: add optional provider parameter to update_session_model, merged into model_config via the shared _merge_model_config_json helper (not hand-rolled SQL). Wire both gateway /model call sites to pass result.target_provider. NousResearch#85721: session_gateway_runtime() had no billing_provider fallback. A CLI session that never ran /model has no gateway_runtime or top-level provider in model_config — billing_provider (written on every session's first accounted API call) is the only durable record. Fix: add billing_provider as the last-resort fallback in session_gateway_runtime(), filtering bare billing buckets (auto/custom) that are not routable identities. Simplify-code findings addressed: - Use _merge_model_config_json instead of 40 lines of branched SQL - Share _BARE_BILLING_PROVIDERS from hermes_state.py (was duplicated as a set in tui_gateway/server.py) - Merge None-filtering from NousResearch#85920 with the billing_provider fallback into one coherent return path Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
…file test test_slash_worker_accepts_profile_home mocks hermes_constants with get_hermes_home=MagicMock(return_value="/tmp/hermes_test"), a str. In production get_hermes_home() returns a Path, and hermes_state.py's module-level DEFAULT_DB_PATH = get_hermes_home() / "state.db" does path division. Under the str mock that becomes str / str, so importing tui_gateway.server inside the patch raises TypeError and the test fails on every main run (slice 4). Wrap the mock return in Path(...) so it matches the real return type. Test-only; no production code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the fork up to NousResearch/hermes-agent@1f8fdc7bd8 (2026-08-14). Previous sync point was 9076ada (2026-08-04, the ea9ae66 merge). Fork-only work on top of upstream shrank from 58 to 35 files: upstream has since salvage-merged several of our PRs (Slack SDK-response handling NousResearch#74658, kanban wake scope NousResearch#78391, the review-lifecycle lane NousResearch#72554 via NousResearch#83412, the /anthropic auxiliary base_url fix), so those files are now byte-identical to upstream and no longer carry a fork delta. Conflict resolution — both sides were kept everywhere; no upstream behaviour was dropped: * hermes_cli/config_defaults.py — our `slack.strip_bot_mentions` default landed next to the upstream keys added in the same block. * tests/gateway/test_slack.py — both sides appended their own test class at the end of the file; upstream's TestNativeTaskCardProgress and our TestSlackAuthoredTextDeduplication both survive. * plugins/platforms/slack/adapter.py, gateway/run.py — our inbound-file trust policy, rich-text dedup, permalink rendering and per-request proxy client merged with upstream's native task cards / streaming. * hermes_cli/kanban.py, kanban_db.py — our respawn-guard work merged with upstream's review lane, `reopen_review_task` and descendant invalidation. One test needed a real fix rather than a textual merge: tests/tools/test_kanban_tools.py::test_request_review_happy_path called the tool without a run id. Upstream has since made the review handoff CAS-guarded — a task running under a live claim may only be moved by the worker that owns the run — so the test now exports the claimed run id the way the dispatcher does for its worker, and exercises the real worker path instead of an anonymous call. Verified: full suite on the merge result reproduces exactly the same 17 files / 25 failures (plus 11 files that cannot import the optional `acp` package) as a clean upstream/main checkout in the same venv, i.e. every red is pre-existing and environment-bound, not a merge regression. The Slack + kanban + send_message slice is fully green (511 tests). Co-authored-by: Junie <junie@jetbrains.com>
`review_requested` and `block_loop_detected` are terminal event kinds that hand a decision back to the origin subscriber, but neither was listed in the gateway notifier's `_WAKE_KINDS`. A `notify+wake` subscription therefore got the passive ping only and the origin agent never took a turn — so an agent that delegated implementation work slept through the "ready for review" handoff and through a task being routed to triage, while the equivalent `blocked` event woke it. Add both kinds to the wake set, add their status strings to the synthetic wake message in every locale, and document which events wake.
19 tasks
…w-handoff fix(kanban): wake the origin on review handoffs and triage escalations
`completed` already puts the worker's summary inside the synthetic wake turn, so the woken creator sees what was done. `review_requested` did not: the summary rode the passive ping only, and the wake turn said just "handed off for review", forcing the woken reviewer to re-read the board (and losing the PR link the worker had already written). Reuse the same first-line handoff the `completed` branch builds, so the existing `gateway.kanban.wake.handoff` string renders it — no new locale keys, no change to the passive message.
…w-summary feat(kanban): carry the review handoff summary into the wake turn
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.
What does this PR do?
Syncs the fork with upstream
NousResearch/hermes-agent, from the previous sync point9076adaca5(2026-08-04) up to1f8fdc7bd8(2026-08-14) — 1658 upstream commits, 2267 upstream files changed. A single merge commit with both real parents, sogit log --first-parentonmainkeeps reading as fork history and future syncs still have a proper merge base.Our own work stays on top of upstream: 48 fork-only commits, now 35 files of fork delta (down from 58). The shrink is not a loss — upstream salvage-merged several of our PRs in the meantime (Slack SDK-response handling NousResearch#74658, kanban wake scope NousResearch#78391, the review-lifecycle lane NousResearch#72554 via NousResearch#83412, the
/anthropicauxiliarybase_urlfix), so those files are byte-identical to upstream now and simply stopped being a delta. Verified file-by-file: every file that dropped out of the delta matchesupstream/mainexactly.Conflicts were resolved keeping both sides everywhere — no upstream behaviour was dropped, and no fork feature was lost.
Related Issue
N/A — routine upstream sync.
Type of Change
Changes Made
upstream/main→ fork (9076adaca5..1f8fdc7bd8).hermes_cli/config_defaults.py— ourslack.strip_bot_mentionsdefault kept alongside the upstream keys added in the same block.tests/gateway/test_slack.py— both sides appended a test class at the end of the file; upstream'sTestNativeTaskCardProgressand ourTestSlackAuthoredTextDeduplicationboth survive.plugins/platforms/slack/adapter.py,gateway/run.py— our inbound-file trust policy (base_urlorigin), rich-text dedup, permalink rendering and per-request proxy client merged with upstream's native task cards / streaming.hermes_cli/kanban.py,hermes_cli/kanban_db.py— our respawn-guard work merged with upstream's review lane,reopen_review_taskand descendant invalidation.tests/tools/test_kanban_tools.py— the one real fix, not a textual merge.test_request_review_happy_pathcalled the tool without a run id. Upstream has since made the review handoff CAS-guarded (a task running under a live claim may only be moved by the worker that owns the run), so the test now exports the claimed run id the way the dispatcher does for its worker — it exercises the real worker path instead of an anonymous call. Also un-glued two function definitions that a previous merge had joined without blank lines.How to Test
scripts/run_tests.sh tests/tools/test_kanban_tools.py tests/tools/test_send_message_tool.py tests/gateway/test_config.py tests/gateway/test_slack.py tests/gateway/test_slack_download_ssrf.py tests/gateway/test_slack_strip_bot_mentions.py tests/gateway/test_slack_sdk_response.py -q→ 7 files, 511 tests passed, 0 failed.scripts/run_tests.sh(full suite) → 17 files / 25 failures + 11 files that cannot import the optionalacppackage.upstream/mainworktree in the same venv → identical 17 files / 25 failures. Every red is pre-existing and environment-bound (missing optional deps, macOS audio stack), not a merge regression.Checklist
Code
fix(scope):,feat(scope):, etc.) — merge commit describes the sync and every conflict resolutionscripts/run_tests.shand compared against a cleanupstream/mainbaselineDocumentation & Housekeeping
docs/, docstrings) — N/A, upstream docs come in with the synccli-config.yaml.exampleif I added/changed config keys — N/A, no new fork config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A,AGENTS.mdis taken from upstreamScreenshots / Logs
Full-suite failures are identical on the merge result and on a clean
upstream/maincheckout (17 files / 25 tests), i.e. the sync introduces no regression.