Commit graph

13812 commits

Author SHA1 Message Date
Brooklyn Nicholson
8795dfa331 refactor(desktop): extract composer pop-out engine into useComposerPopout
Moves the docked↔floating state, dock/float/toggle actions, drag-gesture wiring,
and the on-screen re-clamp effect out of ChatBar into
hooks/use-composer-popout.ts, verbatim. ChatBar passes its composerRef in and
consumes the returned popout state/handlers; the secondary-window gate and the
shared persisted atom stay encapsulated in the hook.
2026-06-30 14:26:39 -05:00
Brooklyn Nicholson
a8f9d089f1 refactor(desktop): extract composer placeholder logic into useComposerPlaceholder
Moves the resting-placeholder state + the conversation-change re-roll effect +
the disabled/reconnecting/starting derivation out of ChatBar into
hooks/use-composer-placeholder.ts, verbatim. The hook owns its own i18n + browse
reset; ChatBar just reads the derived string.
2026-06-30 14:25:21 -05:00
Brooklyn Nicholson
2a84bcb149 refactor(desktop): extract composer "Add URL" dialog into useComposerUrlDialog
Moves the URL dialog's open/value state, autofocus-on-open effect, and submit
(host onAddUrl or an @url: directive) out of ChatBar into
hooks/use-composer-url-dialog.ts, verbatim. ChatBar just wires the returned
openUrlDialog into the context menu and the state into <UrlDialog>.
2026-06-30 14:24:32 -05:00
Brooklyn Nicholson
e009ba57ac refactor(desktop): extract global Esc-to-cancel into useComposerEscCancel
Moves the chat-focused Esc-cancel listener (the latest-handler ref + the
register-once window keydown effect) out of ChatBar into
hooks/use-composer-esc-cancel.ts, verbatim. Encapsulating the latest-closure ref
inside its own hook is the first of the plan's "delete the latest-closure refs"
cleanups: it's no longer a loose ref in the 1.4k-line component, just an
implementation detail of a focused side-effect hook keyed on busy/awaitingInput/
onCancel.
2026-06-30 14:23:35 -05:00
Brooklyn Nicholson
5fdc2acedc refactor(desktop): extract composer branch/worktree engine into useComposerBranch
Moves the CodingStatusRow hand-offs (openInWorktree + branch-off / convert /
list / switch) out of ChatBar into hooks/use-composer-branch.ts, verbatim. The
hook depends only on cwd + draftRef + clearDraft (backend coupling via the
projects store); nothing about ChatBar's render. Dead projects/composer-store
imports drop out of index.tsx.
2026-06-30 14:22:54 -05:00
teknium1
0b3752eede chore(release): add AUTHOR_MAP entry for lEWFkRAD (#53848 salvage) 2026-06-30 12:07:01 -07:00
Jeff Watts
4d2351a528 feat(moa): stream the aggregator response to the user
MoA sessions could not stream: the gateway streaming toggle was a no-op for
provider "moa", so users saw nothing until the entire response finished — minutes
of silence on long turns. The aggregator's reply was always fetched whole.

Root cause was twofold:
  1. conversation_loop hard-disabled streaming for provider in {"copilot-acp",
     "moa"} (MoA grouped with the ACP client, whose facade isn't a stream).
  2. MoAChatCompletions.create() fetched the aggregator response whole via
     call_llm(), which had no streaming mode.

For provider "moa", _create_request_openai_client() returns the MoAClient facade
itself, so the existing streaming consumer already calls
MoAChatCompletions.create(stream=True). We reuse that battle-tested consumer
(text-delta delivery, tool_call reassembly, stale-stream detection, non-streaming
fallback) instead of adding a parallel streaming path.

Changes:
  - call_llm() gains stream/stream_options. When streaming it returns the raw SDK
    stream iterator directly, bypassing _validate_llm_response and the
    temperature/max_tokens/payment fallback chain (which assume a complete
    response). The caller owns reassembly and fallback.
  - MoAChatCompletions.create() runs the references first (unchanged), then when
    stream=True returns the aggregator's raw stream, forwarding stream_options and
    the consumer's per-request read timeout. stream=False is byte-identical to
    before (no stream/stream_options/timeout forwarded).
  - conversation_loop streams MoA only when a display/TTS consumer is present;
    quiet/subagent/health-check paths keep the complete-response path.

Tests: tests/run_agent/test_moa_streaming.py — create() stream/non-stream
branches, stream_options + timeout forwarding, call_llm raw-stream return vs
validated non-stream. Existing MoA tests unchanged (20 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:07:01 -07:00
Max Freedom Pollard
936af2f4f5 Merge consecutive same-role contents for native Gemini
_build_gemini_contents emitted one contents entry per source message and
never merged adjacent same-role entries. Gemini's generateContent requires
strict user/model alternation and rejects consecutive same-role turns with
HTTP 400 ("Please ensure that multiturn requests alternate between user and
model"). A parallel tool call turns into two tool results in a row, which
become two consecutive user functionResponse contents, so every multi-tool
turn produced an unsendable history.

Fold adjacent same-role contents into one by concatenating their parts after
the per-message loop, matching the Anthropic and Bedrock converters. For a
parallel call this yields the grouped multi-functionResponse user turn Gemini
expects.
2026-06-30 11:51:22 -07:00
brooklyn!
885e80df74
Merge pull request #55807 from NousResearch/bb/desktop-split-onboarding
refactor(desktop): split onboarding overlay god file into onboarding/ folder
2026-06-30 13:23:25 -05:00
Brooklyn Nicholson
18d54bf0fd refactor(desktop): split onboarding overlay god file into onboarding/ folder
desktop-onboarding-overlay.tsx (1,291 lines) folded into components/onboarding/
as a cohesive feature folder. Behaviour-preserving — every move is verbatim;
typecheck + lint + tests green.

- index.tsx (665) — overlay shell, Picker, Header/Preparing, API-key catalog +
  ApiKeyForm; re-exports the provider API the settings page consumes.
- flow.tsx (364) — OAuth flow panels (FlowPanel, steps, DeviceCode, CodeBlock,
  ConfirmingModelPanel, DocsLink, Status).
- providers.tsx (118) — provider rows + display/sort (FeaturedProviderRow,
  ProviderRow, KeyProviderRow, providerTitle, sortProviders).
- glyph.tsx (170) — the decode/scramble animation toolkit (pure leaf).

Importers (desktop-controller, providers-settings) repointed to
@/components/onboarding; the overlay test moved to onboarding/index.test.tsx.
2026-06-30 13:21:49 -05:00
Brooklyn Nicholson
abb11c86b9 fix(journey): swap skill/memory inks so drillable rows read as clickable
Memories are the only drillable rows, so give them the primary "clickable"
ink and demote skills (dead-ends) to the muted complement — previously the
non-openable skills wore the link-looking primary color. Flipped in both
the TUI and CLI palettes for parity.
2026-06-30 11:54:16 -05:00
brooklyn!
f99ba56df4
Merge pull request #55331 from xxxigm/fix/desktop-projects-stale-backend
fix(desktop): handle stale backend when creating projects (#54999)
2026-06-30 11:47:04 -05:00
Brooklyn Nicholson
2f7b6cf298 refactor(journey): drop dead braille/orbital render code
The renderer kept a braille canvas, char-field scene, star-glyph/orbital
helpers, and seed/links params from earlier visual iterations that the
final timeline bar chart never uses. Remove them (~190 lines), simplify
the empty-state placeholder, and refresh the module + RPC docstrings to
describe what actually ships.
2026-06-30 11:43:40 -05:00
Jeffrey Quesnelle
c4b59e6463
Merge pull request #55663 from xxxigm/fix/bootstrap-diverged-git-pull
fix(installer): recover bootstrap when managed git clone diverged
2026-06-30 12:38:41 -04:00
Brooklyn Nicholson
59a40330d5 feat(journey): blank gap row between timeline groups
Add a non-selectable spacer row before each slice (except the first) so
groups breathe — the CSS `group + group { margin-top }` equivalent. The
gap counts toward the scroll window but cursor navigation skips it.
2026-06-30 11:36:27 -05:00
Brooklyn Nicholson
ae78326bf6 feat(journey): chronological slice/item tree in the TUI
Collapse the two-step slice list → detail page into one scrollable tree:
each timeline slice is a parent header with its skills + memories nested
under ├─/└─ branch chars, ordered oldest → newest (children now sorted
chronologically in the renderer). One cursor walks the whole tree; Enter
still opens a memory's body. Drops the separate detail mode.
2026-06-30 11:21:25 -05:00
Brooklyn Nicholson
dc61642419 fix(journey): only drill into items that have detail
Skill nodes carry no body in the learning_graph payload, so opening one
dead-ended on "No additional detail recorded yet." Gate Enter/→ to nodes
with body (memories), mark those rows with a › affordance, and only show
the "open" hint when the selected row is drillable.
2026-06-30 11:15:35 -05:00
kshitij
efd87a1545
Merge pull request #55715 from kshitijk4poor/fix/config-migration-no-default-expansion
fix(config): route every migration write through one default-stripping chokepoint
2026-06-30 20:41:29 +05:30
kshitijk4poor
c717be8ded fix(config): route every migration write through one default-stripping chokepoint
A single 'hermes update' / 'hermes -p' could rewrite a hand-curated config.yaml
into a near-full DEFAULT_CONFIG dump (the 'you blow up my profile config on one
tweak' reports). Root cause: migrate_config() had ~16 independent save_config()
call sites, each author deciding ad hoc whether to materialise a value, and many
persisted pure schema defaults with strip_defaults=False. Defaults already merge
transparently at read time via load_config(), so writing them is pure bloat that
also shadows future default changes (see save_config's docstring).

Architectural fix (not a per-site patch): introduce a single _persist_migration()
chokepoint that enforces one invariant — a migration may persist only values that
DIFFER from the current schema default, plus explicit removals/renames of user
data; pure defaults are never written. Every migration write (all 17 sites incl.
the version-bump finalizer) now routes through it. The invariant is mechanically
correct for all cases and verified empirically:
  - pure-default seeds (timezone='', curator/auxiliary.curator blocks, interim
    flag, curator.consolidate=False, empty plugins.enabled) are stripped → merged
    in at read time;
  - non-default values (write_approval=True, model_catalog.ttl_hours=1) preserved
    via explicit-raw-path preservation;
  - behaviour flips (agent.verify_on_stop=False, schema default still 'auto')
    preserved because False != 'auto';
  - data transforms (custom_providers->providers, stt.model relocation,
    write_mode->write_approval, compression.summary_* removal, MCP-disable)
    persist their removals/renames.

An explicitly user-set non-default value (e.g. matrix.require_mention: false) is
preserved across the bump.

Guard tests lock the architecture: an AST check asserts migrate_config() makes no
direct save_config() call (all writes go through _persist_migration), and a
full-range v1->latest test asserts a lean config is never dumped. Two existing
change-detector tests that froze the on-disk representation of default-valued
keys are rewritten to assert the effective value via load_config() (behaviour
contract, not snapshot).

Validation: lean v1->latest migration drops from ~567 bytes to ~196 bytes;
148 config+setup and 196 profile/curator/migrate tests pass on scripts/run_tests.sh.
2026-06-30 20:30:22 +05:30
kshitijk4poor
a5e8cd4d40 fix(memory): degrade gracefully after repeated at-capacity consolidation failures (#42405)
Builds on the zero-match feedback fix (previous commit) to close the silent-hang
symptom: when memory is at capacity, a failed `add`/`replace`/`remove`
consolidation could loop the whole turn to iteration-budget exhaustion and
deliver no user-facing reply.

#41755 turned the at-capacity overflow error into a *commanded* in-turn retry
("...then retry this add — all in this turn"); combined with the fragile
substring-only `replace`/`remove` matching (LLMs can't reliably re-quote a long
entry verbatim), the model loops add↔replace on inexact guesses until the turn
dies. The existing tool_guardrails halt would catch this, but hard_stop_enabled
is opt-in (off by default), so a default install still hangs.

This fixes it at the memory layer without changing global guardrail behavior:
- MemoryStore tracks per-turn consolidation failures; after a cap (3) it drops
  the "retry in this turn" instruction and returns a terminal "leave memory
  unchanged, continue your reply" result, so a failed memory side effect can
  never block the turn's reply.
- The counter resets on any successful write (progress) and at each turn
  boundary (turn_context.reset_consolidation_failures, guarded via getattr so
  plugin memory stores without the method are a no-op).

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-30 20:01:16 +05:30
kyssta-exe
62a1bf4c55 fix(tools): return previews on zero-match in replace/remove to prevent memory retry loops (#42405)
- replace() and remove() now return entry previews and current_entries
  when no entry matches old_text, matching the multi-match and add-limit
  error behavior
- add() limit error also now returns previews for consistency
- Agent can self-correct after a failed replace/remove instead of looping
  blindly until turn budget is exhausted with no user response
2026-06-30 20:01:16 +05:30
xxxigm
83e10a6777 fix(desktop): detect stale backend for projects.create
Probe the projects.* RPC surface, block create with a clear update hint,
and avoid the raw "unknown method" toast. Includes i18n for en, zh, ja,
and zh-hant.

Fixes NousResearch/hermes-agent#54999
2026-06-30 21:23:38 +07:00
xxxigm
217b3c283a test(desktop): cover projects RPC capability probing
Regression tests for stale-backend detection when projects.create is
missing from an older backend that still reports the same semver.
2026-06-30 21:23:38 +07:00
kshitijk4poor
1b3768558e docs(image-gen): align OpenRouter model-resolution docstrings with new precedence
The cherry-picked fix added explicit-kwarg and top-level image_gen.model
resolution but left _resolve_model / _resolve_model_chain docstrings stating
the old 'env override -> config -> DEFAULT_MODEL' order. Document the full
precedence (explicit kwarg -> env -> scoped -> top-level -> default chain) to
match the sibling krea/openai providers.
2026-06-30 19:11:49 +05:30
xxxigm
63731fe856 test(image-gen): cover Nous/OpenRouter top-level model resolution
Assert image_gen.model, explicit model kwargs, and Nous provider wiring
so the config path mismatch cannot regress.
2026-06-30 19:11:49 +05:30
xxxigm
1324add563 fix(image-gen): honor top-level image_gen.model for Nous/OpenRouter
hermes tools persists the selected model to image_gen.model, but the
OpenRouter-compatible provider only read scoped image_gen.<provider>.model
and ignored the dispatch model kwarg — so Nous users always hit the default
quality-first chain and fell back to Gemini.
2026-06-30 19:11:49 +05:30
xxxigm
665d0f0789 test(installer): cover diverged managed-clone recovery in install scripts
Pin the ff-only-then-reset fallback in install.sh and install.ps1 so
bootstrap cannot regress to hard-failing on diverged git history.
2026-06-30 20:11:01 +07:00
xxxigm
a40f22798e fix(installer): reset managed clone when ff-only pull fails
Bootstrap and desktop updates run install.ps1/install.sh, which aborted
with exit 128 when the managed checkout had diverged from origin/main.
Mirror the hermes update recovery path: reset to origin/$BRANCH instead
of failing the repository stage.
2026-06-30 20:11:01 +07:00
kshitijk4poor
824f2279da refactor(registry): drop dead toolset-check helpers after per-tool availability
Follow-up to the per-tool availability derivation: `_snapshot_toolset_checks`
and `_evaluate_toolset_check` had no remaining callers once the four
availability surfaces switched to `_toolset_has_exposable_tools`. Remove both,
drop the no-op `quiet` param from the new helper, and document why
`_toolset_checks` is still written (banner.py reads it via TOOLSET_REQUIREMENTS
to classify unavailable toolsets as lazy-init vs disabled).
2026-06-30 17:47:37 +05:30
xxxigm
efebe451db test(registry): cover mixed terminal toolset doctor false negative
Regression for #54820: a desktop-only helper with a failing check_fn must
not mark the whole terminal toolset unavailable when terminal/process
still pass their per-tool gates.
2026-06-30 17:47:37 +05:30
xxxigm
6e84257717 fix(registry): derive toolset availability from per-tool checks
Doctor and banner used the first check_fn registered for a toolset, so
desktop-only read_terminal gated the whole terminal toolset even though
terminal and process still expose at runtime.

Fixes #54820
2026-06-30 17:47:37 +05:30
Neo
c969090878 fix(cli): clear input-blocking overlays when interrupting a running agent
Interrupting the agent while an approval/clarify/sudo/secret prompt is up
left the overlay state dict set with no thread servicing it. The prompt's
worker thread is torn down on interrupt, but read_only (gated on
_command_running) plus the keypress filter kept the CLI input locked until
the prompt's own timeout expired — the terminal appeared frozen.

Drain and clear all four input-blocking overlays on interrupt via a single
helper (_clear_active_overlays_for_interrupt): approval -> deny,
clarify/sudo/secret -> cancel, each guarded so a dead queue can't block the
others; sudo restores the pre-modal draft. Wired into all three interrupt
paths — new-message interrupt, Ctrl+C, and Ctrl+Q. Blocking overlays now
clear AND fall through so one keypress both clears a stale overlay and
interrupts a still-running agent; the /model picker and slash-confirm
foreground prompts keep their cancel-and-return behavior.

Closes #13618.
2026-06-30 04:49:29 -07:00
teknium1
8e6fd4cfa6 chore(release): add AUTHOR_MAP entry for londo161 (#15795 salvage) 2026-06-30 04:38:43 -07:00
teknium1
fe355d0a27 fix(moa): handle dict/str message shape in MoA response extraction
Sibling of #15795's context_compressor fix. agent/moa_loop.py used the
same response.choices[0].message.content access; while wrapped in
try/except (so no crash), a dict/str-shaped message silently returned
empty. Coerce defensively so the content is actually extracted.
2026-06-30 04:38:43 -07:00
Vladimir Smirnov
9dc6dc062f fix(agent): handle string context compression messages 2026-06-30 04:38:43 -07:00
Vladimir Smirnov
c080a530ae fix(cli): redact status API keys with --all 2026-06-30 04:38:43 -07:00
Gille
a8841e2a68 fix(aux): preserve provider identity for resolved endpoints
_resolve_task_provider_model() flattened any explicit base_url to
provider=custom. Correct for bare/custom endpoints, but wrong for
provider-backed routes (anthropic, qwen-oauth, minimax-oauth,
openai-codex, etc.) whose provider branch adds auth refresh, transport,
or request shaping. MoA reference slots resolved through those providers
lost their identity before the aux call, so e.g. a Codex reference hit
chatgpt.com/backend-api/codex without its Cloudflare headers and got
HTML back (surfacing as a spurious rate-limit).

Keep first-class providers intact when paired with a resolved base_url
via _preserve_provider_with_base_url(); bare/custom/auto/unknown and the
direct openai alias still route through custom.

Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com>
2026-06-30 04:23:27 -07:00
teknium1
1cae1bd0de test(cli): deterministically join bg worker thread instead of polling deadline
test_background_task_registers_thread_local_approval_callbacks polled a
2s wall-clock deadline waiting for the background daemon thread to pop
its entry from _background_tasks. Under loaded CI the thread's
finally-block cleanup could lag the deadline, flaking the final
'assert not cli._background_tasks'. Join the actual worker thread
(timeout=10) so the wait ends exactly when the thread finishes.
2026-06-30 04:23:03 -07:00
teknium1
6148a9a3fe chore(release): map nnnet author email for PR #25142 salvage 2026-06-30 04:23:03 -07:00
nnnet
5582b51a68 fix(gateway): stop poisoning the LLM prompt with STT-mode chatter
The STT-failure enrichment templates injected setup instructions —
"no STT provider is configured", "a direct message has already been
sent", and a "hermes-agent-setup" skill mention — into the LLM-visible
prompt. That text persists in conversation history, so after one STT
failure the model kept volunteering Whisper/Vosk setup advice on every
later voice turn, even after transcription started working (observed in
prod on gpt-5-nano). The gateway also fired a hardcoded English notice
via _stt_adapter.send(), producing a second, wrong-language reply that
TTS then spoke aloud.

- Neutralize all enrichment templates: success passes the transcript
  through as a plain quoted line; every failure branch emits a single
  [voice message could not be transcribed] marker.
- Move the operator-facing failure cause to logger.info so it stays
  diagnosable in container logs without leaking into the prompt.
- Remove the hardcoded English _stt_adapter.send() notice; the LLM now
  produces one coherent reply in the user's language.
- Update the gateway STT tests to assert the neutral contract.

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
2026-06-30 04:23:03 -07:00
Teknium
cbe397ef45
fix(agent): merge consecutive assistant messages before API replay (#29148, #49147) (#55603)
* fix(agent): merge consecutive assistant messages in repair_message_sequence

Strict OpenAI-compatible providers (DeepSeek v4, Moonshot/Kimi) reject a
replayed history where an assistant message carrying tool_calls is
immediately followed by another assistant message instead of its tool
results — HTTP 400 'An assistant message with tool_calls must be
followed by tool messages...'.

repair_message_sequence (the defensive belt run before every API call)
fixed orphan-tool and consecutive-user shapes but never merged
consecutive assistant messages. Adds a Pass 0 that collapses adjacent
assistant turns into one — union of tool_calls, concatenated content,
carried reasoning_content — covering both reported shapes:
  - parallel tool calls split across two assistant turns (#29148)
  - content-only assistant followed by tool_calls-only assistant (#49147)

A tool result or user turn between two assistants blocks the merge
(distinct, valid rounds). Runs before Pass 1 so the merged union of
tool_call ids is known to the orphan-tool filter.

Closes #29148, #49147.
Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: woaini30050 <woaini30050@users.noreply.github.com>
Co-authored-by: weidzhou <weidzhou@users.noreply.github.com>

* fix(agent): exempt codex Responses interim turns from assistant merge

The Pass 0 consecutive-assistant merge collapsed codex_responses interim
turns, which legitimately stay separate — each carries its own encrypted
continuation state (codex_reasoning_items / codex_message_items) that
must replay verbatim. Skip the merge when either side is a codex interim
(has codex_reasoning_items / codex_message_items / finish_reason=='incomplete').

Fixes the slice-2 regression in test_run_agent_codex_responses.py
(test_duplicate_detection_distinguishes_different_codex_{reasoning,message_items}).

---------

Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: woaini30050 <woaini30050@users.noreply.github.com>
Co-authored-by: weidzhou <weidzhou@users.noreply.github.com>
2026-06-30 04:22:56 -07:00
Teknium
d2d470e321
test(compression): tolerate safe contention rollback in concurrent-fork test (#55597)
The concurrent-compression regression asserted the parent ends with exactly
one child. Under heavy CI write contention the lock winner's child
create_session can exhaust its SQLite retry budget, and _compress_context
deliberately rolls the live id back to the still-indexed parent rather than
orphaning a child (the create-failure rollback in
agent/conversation_compression.py). That safe rollback leaves zero children
and is correct — so the exact == 1 assertion flaked under load.

Assert the actual invariant instead: children <= 1 (a 2+ fork is the bug
Damien's incident is about), rotated <= 1, and rotated == n_children. A
mutation check (force the lock to always acquire) confirms the relaxed
assertion still fails hard on a real 2-child fork.
2026-06-30 04:22:47 -07:00
fayenix
d6c53dcdcb fix(gateway): stop per-turn agent-cache eviction from model + message_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 #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 #9893/#48031) and
the wrong-premise reset_context_note plumbing from the original PR were
dropped.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-06-30 04:22:41 -07:00
Teknium
e7ca53e6b8
fix(moa): disabled presets no longer hijack a plain model switch (#55598)
exact_moa_preset_name matched any bare model name equal to a preset key,
regardless of the preset's enabled flag. On the no-explicit-provider switch
path (PATH B in model_switch.py), a plain /model switch whose name collided
with a preset key (e.g. "default") silently pivoted the session onto the MoA
virtual provider — even when the user had set enabled: false to opt out
(issue #55187). The LLM driving a routine model switch could land on a broken
moa provider with empty default_preset / unconfigured aggregator credentials.

Gate the implicit bare-name match on the per-preset enabled flag. Explicit
selection via --provider moa / the model picker uses PATH A and does not go
through exact_moa_preset_name, so a disabled preset stays reachable when the
user explicitly asks for it.
2026-06-30 04:22:32 -07:00
teknium1
bff61f558f feat(plugins): enable-time consent prompt for tool_override grant
Builds on memosr's sink-level opt-in gate (#29249). Enabling a
non-bundled plugin now surfaces the privileged allow_tool_override
decision at `hermes plugins enable` time instead of leaving the
operator to discover the config key after a runtime rejection.

- `hermes plugins enable <name>` prompts for non-bundled plugins:
  'Allow this plugin to replace built-in tools?' Default is deny
  (blank Enter / non-interactive stdin / EOF all fail closed).
- --allow-tool-override / --no-allow-tool-override flags for
  non-interactive and scripted use (and a future desktop checkbox).
- Bundled plugins are trusted: never prompted, no entry written.
- Writes plugins.entries.<key>.allow_tool_override, the same key the
  sink gate reads (manifest.key == discovery key), so consent and
  enforcement compose end to end.
2026-06-30 04:00:42 -07:00
memosr
12f5624a76 fix(security): bind tool_override authorization to handler's defining plugin module
egilewski found the prior sink gate was transient: it only applied while
PluginManager executed register(ctx). A plugin could defer a direct
registry.register(..., override=True) to a post-load callback/thread, after
the scope was cleared, and still replace a built-in.

Make authorization durable by binding it to where the handler is DEFINED
(handler.__globals__['__name__']) rather than to call timing. At load, each
plugin's module namespace is mapped to its allow_tool_override opt-in in a
table that is never cleared. The sink resolves the handler's owning plugin
module and rejects an override from any plugin namespace without opt-in,
regardless of when or on which thread the call happens. Plugin namespaces
with no recorded policy are treated as not-opted-in (fail-closed). Built-in
and MCP handlers live outside the plugin namespace and are unaffected.

Adds a regression test for the delayed/post-load direct-registry override.
2026-06-30 04:00:42 -07:00
memosr
3101222312 fix(security): enforce tool_override opt-in at registry sink to close direct-import bypass
The opt-in gate lived only in PluginContext.register_tool, so a plugin
could bypass it by importing tools.registry and calling
registry.register(..., override=True) directly. Enforce the same gate at
the sink: during plugin load, the registry rejects an override from a
plugin without operator opt-in regardless of the path taken. Built-in and
MCP registrations (no active plugin scope) are unaffected.

Adds a regression test covering the direct-registry bypass.
2026-06-30 04:00:42 -07:00
memosr
179eb8c2a3 fix(security): require operator opt-in for plugin tool_override to prevent silent built-in tool replacement
The tool_override flag landed in v0.14.0 (#26759) so plugins can replace
a built-in tool with their own implementation. It works as advertised
but there is no trust gate, so any enabled third-party plugin can
silently override any built-in like shell_exec, write_file, or web_fetch
and exfiltrate everything the agent invokes through it. The only trace
is a DEBUG-level log line.

Compare with ctx.llm (#23194) which does gate the equivalent privilege
escalation: overriding the provider requires
plugins.entries.<id>.llm.allow_provider_override: true in config.yaml.
The policy shape exists, it just was not extended to tool overrides.

Fix:

* Add PluginToolOverrideError(PermissionError) for the gate failure.

* register_tool() now checks _tool_override_allowed(name) when
  override=True. Bundled plugins (manifest.source == 'bundled') are
  trusted by default. Every other source requires
  plugins.entries.<plugin_id>.allow_tool_override: true in config.yaml.

* fail-closed: if config.yaml cannot be loaded for any reason,
  _tool_override_allowed returns False. Same posture as
  MSGraphWebhookAdapter.connect() in #22353.

Backwards compatibility:

* Bundled plugins: no change (source == 'bundled' short-circuits the
  gate).
* Third-party plugins not using override: no change (gate is only
  consulted when override=True).
* Third-party plugins using override: registration fails until the
  operator opts in. The error message includes the exact config path
  to add, so the fix is one config edit away for legitimate use cases.
  Same migration path users went through for allow_provider_override
  after #23194 landed.

Regression tests:

* tests/hermes_cli/test_plugins.py::test_register_tool_override_replaces_existing
  and ::test_register_tool_override_on_new_name_is_noop_path were
  written before the gate existed. Updated their test configs to
  include allow_tool_override: true under
  plugins.entries.<plugin_id>, mirroring how a legitimate operator
  would now grant the privilege.

* New regression test ::test_register_tool_override_blocked_without_operator_opt_in
  exercises both the PluginManager-catches-error path (built-in tool is
  preserved, attacker plugin is skipped) and the direct-call path
  (PluginToolOverrideError is raised with a message that names the
  config key to set). Verified the test fails without this fix and
  passes with it.

* All 73 tests in test_plugins.py continue to pass.
2026-06-30 04:00:42 -07:00
Zane Ding
ac380050ea fix(credential-pool): distinguish OpenRouter upstream 429s from account 429s
OpenRouter returns 429 in two shapes: an account-level throttle on the
user's key, and an upstream-provider throttle (DeepSeek/Anthropic/etc.
rate-limiting OpenRouter's aggregate traffic). The classifier treated
both identically and rotated/exhausted OPENROUTER_API_KEY on every 429 —
burning the key for ~24min and silently disabling auxiliary features
(compression, summarization, vision) on an upstream throttle where the
key was healthy.

Add a FailoverReason.upstream_rate_limit classified from OpenRouter's
unambiguous wrapper message "Provider returned error" (the same signal
the metadata-raw parser already trusts). Recovery skips credential
rotation and defers to the fallback chain to switch models instead.

Co-authored-by: Hermes Agent <127238744+teknium1@users.noreply.github.com>
2026-06-30 03:57:14 -07:00
Teknium
abca77615a chore(release): map Jeffgithub0029 author email for #28558 salvage 2026-06-30 03:51:08 -07:00