mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
129 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8b6fde3a35 |
fix(model): default /model switches to session scope everywhere
Flip the resolve_persist_behavior() fallback from persist-to-config to session-only. A plain /model <name> (typed or via any picker — CLI, TUI/Desktop, gateway) now affects only the current session; --global persists explicitly, and model.persist_switch_by_default: true restores the old opt-out behavior for users who want switches to stick. This is the root cause behind the recurring 'session switch applied globally' bug class (#61458, #63083, #58290, #61190): every surface funnels its no-flag default through this one function, so per-surface patches kept missing paths. Fixing the default fixes all surfaces at once: CLI typed + picker, TUI/Desktop config.set + slash, gateway typed + inline picker. Builds on liuhao1024's #58371 (--provider session scoping, cherry-picked as the previous commit) and supersedes the per-surface #61488. |
||
|
|
0d6d73525d |
fix(model): default --provider switches to session-only persistence
When /model is called with --provider but without --global or --session,
the switch now defaults to session-only instead of persisting to
config.yaml. Provider switches are typically exploratory — the user is
trying a different backend for this conversation, not reconfiguring the
default. --global can still force persist when desired.
This addresses a regression from
|
||
|
|
8bec1540f0 |
tui: centralize RID-strip in format_model_for_display + apply to switch banner
Address review on PR #36998: the inline ri.<service>..<ns>. stripper in _get_status_bar_snapshot was a one-off heuristic that: * lived in cli.py with no shared call site, so the switch-confirmation banner ("✓ Model switched: ri.language-model-service..…") and the [Note: model was just switched from … to …] system-prompt nudge still printed the full opaque RID — exactly what the screenshot reported; * split on '..' and re-split on '.', which would mis-handle any RID whose namespace token isn't a single dotted segment. Refactor: * New module-level helper hermes_cli.model_switch.format_model_for_display matches on a startswith() allow-list (_OPAQUE_MODEL_PREFIXES) and returns the trailing slug. Falls through to the original string for every non-Palantir id, so HF paths (meta-llama/Llama-3.3-70B-Instruct), plain Claude/GPT names, .gguf paths, and aliased ids are untouched. Allow-list is extensible — add a prefix tuple entry for future proxies that wrap real names in a namespace (Bedrock ARNs are already covered by the slash-split fallback and have a different shape). * _get_status_bar_snapshot() now delegates to the shared helper after the reverse-alias miss (so configured aliases still win over the helper output). * cli.py::_handle_model_command — both confirmation-print blocks (~7720 and ~7975) now run result.new_model AND old_model through the formatter before they hit _cprint() and the _pending_model_switch_note text. * gateway/run.py model-switch handler (~10915) — same treatment for _pending_model_notes[_session_key] and the t('gateway.model.switched', model=…) confirmation line returned to the gateway client. The formatter is DISPLAY-ONLY. The session_model_overrides map, ModelSwitchResult.new_model, persistence to config.yaml, alias lookups, and every wire call still carry the full opaque RID — Palantir's API requires it. Verification: unit reproducer covers (a) all four Palantir model RIDs from this user's config stripped to the trailing slug, (b) plain model names (claude-4-7-opus-20260101, gpt-5.4, HF paths, empty string) passed through unchanged, (c) prefix-only edge preserved (no infinite-loop / empty-output regression). Refs: PR #36998 review feedback; screenshot showed model banner still printing the long RID after the original status-bar-only fix landed. |
||
|
|
4e02320ed9 |
tui: friendlier model display + group same-endpoint providers in picker
Two related TUI quality-of-life fixes for users running multiple models
behind a single proxy/aggregator (e.g. Palantir Foundry, Bedrock,
self-hosted vLLM behind a single key).
1. _get_status_bar_snapshot() — friendlier model name in the status bar.
Long catalog IDs (Palantir RIDs like
``ri.language-model-service..language-model.anthropic-claude-4-7-opus``)
were truncated to ``ri.language-model-ser...`` by the existing 26-char
slash-split, leaving the user with no way to tell which model is active.
The status bar now:
* Reverse-looks up the model id in config.yaml ``model_aliases:`` /
``model.aliases:`` and shows the shortest configured alias when one
exists (so users who set up a friendly alias get it for free).
* Falls back to stripping Palantir's ``ri.<service>..<ns>.`` RID prefix
before length-truncation, so the truncated label carries the actual
model identity (``anthropic-claude-4-7-opus``) instead of the URN
scheme.
* Reverse-alias map is cached at module level (config is loaded once
per session; no need to re-resolve on every status-bar refresh).
2. list_authenticated_providers() section 3 — group ``providers:`` entries
by (api_url, key_env, api_mode), mirroring section 4's existing grouping
for ``custom_providers:`` lists.
Before: a Palantir Foundry config with two Anthropic-proxy entries
(``palantir-claude46`` + ``palantir-claude47``) produced two near-
duplicate picker rows labelled ``Palantir Claude 4.6 Opus`` and
``Palantir Claude 4.7 Opus`` — same endpoint, same PALANTIR_TOKEN,
same anthropic_messages wire protocol, differing only by model id.
After: those entries collapse into a single ``Palantir Claude`` row
with both models in the dropdown. Same-host entries with a different
``api_mode`` (e.g. an OpenAI-compat ``palantir-gpt54`` alongside the
Anthropic claude rows on the same host) keep distinct rows since
the wire protocol differs — same safety invariant section 4 already
enforced for ``custom_providers:``.
Group display name strips per-version trailing tokens (``Palantir
Claude 4.7 Opus`` → ``Palantir Claude``) only when the prefix has
≥2 words, so single-word names aren't over-trimmed.
The new code records (raw_display_name, api_url) into
_section3_emitted_pairs for every raw entry that joined the group, so
section 4's compatibility-merged ``custom_providers`` view (built by
``get_compatible_custom_providers()`` which calls
``providers_dict_to_custom_providers()`` to convert ``providers:``
into custom-provider shape) still dedupes against this grouped row.
Manual smoke test on a config with three Palantir entries
(claude-4.6, claude-4.7, gpt-5.4): before — 3 picker rows; after — 2
picker rows (1 row "Palantir Claude" with 2 models, 1 row
"Palantir GPT-5.4" with 1 model).
|
||
|
|
2ae195673e |
fix: widen metadata-preserve guard to list-of-dicts models form
The dict-form guard from PR #67878 only covered the mapping shape ({model: {context_length: ...}}). The list-of-dicts shape ([{id: model, context_length: ...}]) is also a supported config form (per _declared_model_ids) and was still being replaced with a flat list of strings, destroying per-model metadata. Sibling site for #67841. |
||
|
|
311bacb572 |
fix(model-switch): preserve per-model metadata dict in _save_discovered_models_to_config (#67841)
When custom_providers[].models uses the mapping form to store per-model metadata (e.g. context_length), _save_discovered_models_to_config must not replace it with a flat list of strings. Add a guard that skips entries whose models value is a dict, preserving the user's curated metadata. The regression was introduced by PR #65652, which added the auto-save helper without considering the dict form. |
||
|
|
54459e76ed
|
fix: speed up CLI /model picker by skipping non-current custom provider probing (#65652)
* fix: speed up CLI /model picker by skipping non-current custom provider probing The CLI /model picker calls build_models_payload() with default probe_custom_providers=True, which live-fetches /v1/models from every saved custom endpoint on every open. The GUI/desktop picker already passes probe_custom_providers=False for snappiness. Match the GUI behavior: skip probing non-current custom providers, but still probe the current one so its model list stays accurate. Users can force a full re-fetch with /model --refresh. Fixes #65650 Related: #63583 * fix(cli): forward force_refresh to model picker probe flags When /model --refresh is used, the CLI model picker must probe all custom providers to refresh their model lists — not skip them. Normal bare /model still skips non-current probes for speed. Mirrors the existing desktop/TUI behavior. Add regression test for both normal and refresh flag forwarding. Fixes #65650 * fix: auto-save discovered models to config for discover-once caching After a successful /v1/models probe, persist the discovered model list back to config.yaml under the matching custom_providers entry. This makes discover_models: false meaningful out of the box — users get a populated cache after the first probe instead of a stale 1-model list. - Add _save_discovered_models_to_config() helper - Call after successful fetch_api_models in section 4 probe path - Skip config write when model list hasn't changed - Idempotent — no-op on empty api_url or model_ids Tests: 4 new tests covering auto-save, empty-probe skip, unchanged skip, and no-op-on-empty-args. All 4 pass. Refs: #65652, #65650 --------- Co-authored-by: ajzrva-sys <302567740+ajzrva-sys@users.noreply.github.com> |
||
|
|
3f84b7a163 |
feat: add /model --once one-turn model override (#29914)
Adds --once to /model across CLI, TUI, and gateway: switch model for the next turn only, restoring the previous model in a finally block so success, exception, and interrupt all revert. Parsing extends parse_model_flags_detailed(); resolve_persist_behavior() treats --once as a persistence opt-out; --global + --once is rejected. Salvaged from PR #29923 (image-generation lane split to #59815 per review; conflict resolution against current main by the maintainers). |
||
|
|
d59b79fadd
|
fix(model-picker): show exhausted-pool providers in interactive /model picker (#66584)
Salvages #66257 by @oppih (CI attribution check blocked the external branch from merging). When a provider's credential pool has entries but all are temporarily rate-limited (exhausted), list_authenticated_providers() excluded the provider from the interactive /model picker. Rate limits are per-model for many providers (e.g. Google Gemini), so an exhausted key for model-A may still work for model-B — the user should still be able to select a different model under the same provider. Adds a for_picker flag to list_authenticated_providers() that relaxes the credential-pool availability check for the picker path only, falling back to pool.has_credentials() when the pool has entries but none are currently available. The runtime resolution path (get_authenticated_provider_slugs) is unchanged, preserving the #45759 invariant that exhausted pools do not count as authenticated. Co-authored-by: oppih <oppih@users.noreply.github.com> |
||
|
|
c7aa01ff06 |
fix(model-switch): override stale api_mode with host-mandated mode on OpenAI-direct switch
Switching to a GPT-5.x model on api.openai.com while the session carried a stale chat_completions api_mode (e.g. from a prior openrouter default) left the request on /v1/chat/completions, which 400s with "Function tools with reasoning_effort are not supported" once the switched model's reasoning is applied. switch_model() only re-derived api_mode inside the provider-changed branch, so a same-provider/carryover switch kept the wrong wire protocol. Add host_mandated_api_mode(base_url): the endpoints that accept exactly one protocol (api.openai.com -> codex_responses, api.anthropic.com / *…/anthropic* -> anthropic_messages, api.kimi.com /coding -> anthropic_messages, bedrock-runtime -> bedrock_converse), matched by EXACT hostname so lookalike hosts and path-segment spoofs are rejected (#32243). switch_model() now uses it to override a stale carried api_mode, not merely fill an empty one; determine_api_mode() shares the same helper. Credit sjiangtao2024 (#15880) for the recompute-before-validation approach; this strengthens it from fill-if-empty to a host-mandated override. Co-Authored-By: sjiangtao2024 <siage@139.com> |
||
|
|
2b5d4ae916
|
fix(model): merge configured models into picker rows (#63055)
Preserve the root cause and precedence direction from #43538 while applying the merge before truncation and covering all declared model shapes. Co-authored-by: liuhao1024 <sunsky.lau@gmail.com> |
||
|
|
8c77206859 | refactor(model): gate picker rows by runtime capability | ||
|
|
938c2622f6 |
fix(model_switch): filter /model picker for unregistered providers (#57503)
list_authenticated_providers() emits picker rows for every slug in PROVIDER_TO_MODELS_DEV that has any credential env-var set. Several of those slugs (notably 'mistral') have no PROVIDER_REGISTRY entry, so resolve_provider() rejects them as 'Unknown provider' once the user selects a model — leaving the picker showing rows that cannot actually be selected. Add a resolve-gate in section 1: if PROVIDER_REGISTRY.get(hermes_id) is None, skip the slug. The picker now only lists providers that can actually be switched to at runtime. This automatically resolves the duplicate-Mistral dedup symptom too: once the broken-from-models.dev row is filtered, the conflict between PROVIDER_TO_MODELS_DEV['mistral'] and a custom_providers 'Mistral' row is moot. Composes with #50289 (which promotes mistral to first-class via the provider-plugin path): when that lands, PROVIDER_REGISTRY gains a 'mistral' entry and the gate becomes a no-op for it. No conflict. Tests (regression suite): - tests/hermes_cli/test_model_switch_filter_unresolved.py (new, 4 tests): Picker excludes 'mistral' when MISTRAL_API_KEY is set; 'deepseek' and 'xai' (PROVIDER_REGISTRY-backed) still appear; 'mistral' stays excluded when no key is set. Confirmed by reverting the fix and seeing the test fail with 'mistral leaked into /model picker'. Cross-checked against the existing 51 test_model_switch_* and test_custom_provider_* cases — 55/55 PASS, no regressions. |
||
|
|
1e75744b79 | refactor(model): centralize picker credential availability | ||
|
|
3a67a7be55 |
fix(model-switch): don't treat an exhausted credential pool as authenticated
An aggregator whose pooled credentials are all exhausted/dead still counted as an authenticated provider during no-provider /model resolution. It then won the model-name match, was set as the sticky session provider, and poisoned every later switch with "empty API key" errors while still routing through the dead aggregator. list_authenticated_providers now requires a pool to have at least one available entry (has_available, not has_credentials / bare key presence) at all three credential-pool gates. Simple token-style entries that don't parse into exhaustion-tracked entries keep the prior behaviour, so providers whose creds live only in the auth-store credential_pool still appear. Fixes #45759 |
||
|
|
0629caac62 | fix(model): derive catalog policy from declarations | ||
|
|
5f00f36ba9 | fix(model): probe no-key custom provider catalogs | ||
|
|
db117af478 |
review fixes: openai-api pricing route normalization, GA pricing_version, invariant tests
Phase-2 review findings addressed:
- resolve_billing_route: normalize the "openai-api" picker slug to the
"openai" billing provider — without this the ("openai", <model>)
_OFFICIAL_DOCS_PRICING keys (incl. every pre-existing gpt-4o/gpt-4.1
entry, not just 5.6) were unreachable when the provider is openai-api.
- pricing_version: drop the "preview" tag (GA 2026-07-09 at same rates).
- model_metadata comment: dict order is cosmetic — lookups length-sort
keys at match time; the old comment implied a positional invariant.
- model_switch comment: note "sol" is a series codename, not a generic
quality word.
- tests/hermes_cli/test_gpt56_registration.py: behavior contracts (no
list snapshots) — sol > terra/luna > 5.5 sort invariant, pricing
reachability from both openai and openai-api routes, cache-write
1.25x / cache-read 0.10x input relation.
|
||
|
|
bd767b574b |
feat(openai): complete gpt-5.6 registration — context, codex catalog, native picker, pricing
PR #61578 added the GPT-5.6 series (sol/terra/luna) to the two aggregator surfaces (OPENROUTER_MODELS, _PROVIDER_MODELS[nous]). This completes the registration on the remaining surfaces per the standard add-model checklist: - agent/model_metadata.py: DEFAULT_CONTEXT_LENGTHS 1.05M (direct API, same as gpt-5.5; more-specific keys precede gpt-5.5 for longest-substring matching) + _CODEX_OAUTH_CONTEXT_FALLBACK 272K for all three slugs. Without these the direct-API fallback matched generic "gpt-5" = 400K. - hermes_cli/codex_models.py: DEFAULT_CODEX_MODELS + forward-compat templates so ChatGPT-OAuth (openai-codex) pickers surface the series. - hermes_cli/models.py: _PROVIDER_MODELS[openai-api] (native API picker). - agent/usage_pricing.py: _OFFICIAL_DOCS_PRICING snapshot — sol 5/30, terra 2.50/15, luna 1/6 per 1M in/out; cache read 0.10x input, cache write 1.25x input (OpenAI billing change starting with the 5.6 series). GA 2026-07-09 at preview rates. Sol Fast mode (Cerebras tier) excluded. - hermes_cli/model_switch.py: rank "sol" as a flagship suffix so /model gpt resolves to gpt-5.6-sol, not alphabetical-first luna. Verified: registry E2E via real imports (both context tables, codex forward-compat from a gpt-5.5 template, billing-route lookup for openai/gpt-5.6-sol -> 5.00/M), alias resolution on openai-codex and openai-api resolves to gpt-5.6-sol; 183 targeted tests pass (model_metadata, usage_pricing, codex_models, model_catalog). |
||
|
|
b3bee33ab3 | fix(tui): keep bare custom model listing stable | ||
|
|
4b4f058860 | fix(tui): probe active custom model provider | ||
|
|
fc18d15f40 | fix: preserve static custom provider models | ||
|
|
fe5054bccf | fix(desktop): avoid probing custom providers on model picker open | ||
|
|
6eb39c2bbe
|
fix(opencode-go): heal stripped /v1 base_url so non-minimax models stop 404ing (#57585)
OpenCode Go serves minimax/qwen via Anthropic Messages (base URL without /v1 — the SDK appends /v1/messages) and glm/kimi/deepseek/mimo via OpenAI chat completions (base URL WITH /v1). The runtime stripped /v1 for anthropic-routed models, and the TUI/desktop + gateway persisted that stripped URL to model.base_url. Every later chat_completions model then POSTed to https://opencode.ai/zen/go/chat/completions — a 404 (the marketing site). Result: only minimax worked; glm/deepseek/kimi all 404ed. - New normalize_opencode_base_url(): symmetric /v1 normalization — strip for anthropic_messages, re-append for chat_completions / codex_responses on opencode.ai hosts (heals persisted stripped URLs; custom proxy overrides untouched) - Applied at all three former one-way strip sites (resolve_runtime_provider x2, switch_model) - opencode_model_api_mode: all Qwen models on Go AND Zen now route via /v1/messages per current published endpoint tables (previously only qwen3.7-max on Go — qwen3.6-plus etc. would 404 the same way) - Catalog refresh: Go gains deepseek-v4-pro/flash, glm-5.2, kimi-k2.7-code, minimax-m3, qwen3.7-plus; Zen gains glm-5.2, kimi-k2.7-code, minimax-m3, qwen3.7-plus Reported by IndieSuperhuman on X: opencode-go 404s for any model other than minimax. |
||
|
|
ed4123792c |
refactor(providers): dedupe extra_headers normalizer + key picker groups by headers
Follow-up to @helix4u's #57336 salvage. Two review findings: - W1: model-picker grouped custom-provider rows by (api_url, credential, api_mode) but NOT extra_headers. Entries sharing a URL+credential+api_mode yet declaring different headers (e.g. per-tenant routing behind one proxy) collapsed into one row and probed /models with whichever header set was seen first (order-dependent). Fold a canonical header identity into group_key so distinct header-authed endpoints stay separate; drops the now-dead first-non-empty merge branch. - W2: the extra_headers stringify+None-filter comprehension existed in 5 copies (config.py x2, runtime_provider.py, model_switch.py, models.py). Extract one shared hermes_cli.config.normalize_extra_headers primitive; all sites now call it. Tests: +normalize_extra_headers unit tests, +regression test proving two same-endpoint entries with different headers stay distinct and each probes with its own headers. 223 targeted tests pass; ruff clean. |
||
|
|
ab40e952f3 | fix(providers): pass extra headers to model discovery | ||
|
|
f54c52800a |
fix(models): scope live-first picker merge to opencode aggregators only
Follow-up to the salvaged #49129 commit. The original change flipped the shared generic-provider merge in provider_model_ids() to live-first unconditionally, which regressed curated-first for single providers (kimi/zai, #46309) — and the PR encoded that regression by flipping the kimi-coding and zai test assertions to expect live-first. Gate live-first on an explicit _LIVE_FIRST_PICKER_PROVIDERS set ({opencode-zen, opencode-go}); every other provider keeps curated-first. Also widen the uncapped picker + live-first sets to opencode-go, which has the same 70+ model catalog problem as opencode-zen. Restore the kimi-coding curated-first test and rewrite the merge-order test to assert the per-provider contract. |
||
|
|
f98ffbc246 | fix(models): live-first merge + update opencode-zen catalog + uncap aggregator picker | ||
|
|
a5d1f68c74 |
refactor(moa): share one virtual-provider row builder across pickers
Follow-up on the gateway-picker salvage: the cherry-picked change added a second copy of the MoA virtual-provider row in model_switch.py, duplicating inventory._moa_provider_row (same slug/name/preset-models, identical extra fields). Make _moa_provider_row take a bare current_provider string and reuse it from the gateway picker path so the row shape lives in one place and the two surfaces can't drift. |
||
|
|
ed54469d06 | fix(gateway): show MoA presets in model picker | ||
|
|
d712a7fd73
|
fix(model-picker): surface the current custom/uncurated model in picker rows (#53457)
A model selected via the CLI (e.g. /model openrouter/<uncurated-name>) was absent from every model picker — the main picker AND the MoA reference/ aggregator slot pickers — because each provider row only carried its curated catalog. Inject the current model at the front of its provider's row so it is selectable and shown everywhere. |
||
|
|
c6575df927
|
feat(moa): expose MoA presets as selectable virtual models (#46081)
* feat(moa): expose MoA presets as selectable virtual models Reconstructed onto current main (PR #46081's base had diverged with no common ancestor, marking the PR dirty so CI never dispatched). MoA is now a virtual provider: each named preset is a selectable model under provider 'moa', and the preset's aggregator is the acting model that answers and calls tools. Reference models fan out in parallel via a bounded ThreadPoolExecutor (the same batch pattern delegate_task uses) — all references dispatched at once, collected when every one finishes, then handed to the aggregator. Output order is preserved, failures and the MoA-recursion guard stay isolated per reference. - Removed the old mixture_of_agents model tool and moa toolset. - Added moa as a virtual provider in the provider/model inventory. - /moa is shortcut behavior over model selection (default preset / named preset / one-shot prompt). - Dashboard + Desktop manage named presets; presets appear in model pickers. - Parallel reference fan-out in agent/moa_loop.py with regression test. * fix(moa): thread moa_config through _run_agent to _run_agent_inner The reconstructed gateway MoA wiring declared moa_config on _run_agent (the profile-scoping wrapper) and used it inside _run_agent_inner, but the wrapper never forwarded it — _run_agent_inner had no such parameter, so the runtime hit NameError: name 'moa_config' is not defined on the compression-failure session sync path. Add moa_config to _run_agent_inner's signature and forward it from both wrapper call sites (multiplex and non-multiplex). Caught by tests/gateway/test_compression_failure_session_sync.py on CI shard test(4). * fix(moa): classify moa as a virtual provider in the catalog The moa virtual provider has no PROVIDER_REGISTRY/ProviderProfile entry, so provider_catalog() fell through to the default auth_type="api_key" with no env vars — tripping two catalog invariants: - test_provider_catalog: api_key providers must expose a credential env var - test_provider_parity: every hermes-model provider must be desktop-configurable moa already declares auth_type="virtual" in HERMES_OVERLAYS; consult that overlay as an auth_type fallback so the catalog reports moa as virtual (no real credential, no network endpoint). Exempt virtual providers from the desktop parity union check the same way 'custom' is exempt — derived from the catalog, not a hardcoded slug, so future virtual providers are covered too. |
||
|
|
1a435a6d5d |
fix(model-switch): prevent custom-provider misattribution in model picker (#48305)
When the current provider is a custom endpoint (custom or custom:*), the model switch pipeline must NOT auto-switch to a native provider/OpenRouter based on a static-catalog match. The user explicitly configured their own endpoint and the same model name may be served there; silently rewriting model.provider destroys their config. - detect_static_provider_for_model(): skip the static-catalog scan when the current provider is custom/custom:* - switch_model() Step e: extend is_custom to cover custom:* so the detect_provider_for_model() last-resort fallback cannot fire Salvaged from #48351 by Elshayib (authorship preserved). Fixes #48305 |
||
|
|
791c992b55 |
fix(model_switch): route typed configured models off openai-codex (#45006)
A typed `/model <name>` where `<name>` is declared under `providers.<slug>` or `custom_providers` — but typed while the current provider is a soft-accepting one (e.g. `openai-codex`) — stayed on the current provider and was swallowed as an unknown hidden Codex model, instead of routing to the provider that actually declares it. Add configured-provider exact-match detection (`_configured_provider_matches`) and a new Step d.5 in `switch_model`: if the typed model is declared in user/custom provider config, route to that provider BEFORE `detect_provider_for_model()` guesses from static catalogs and BEFORE the common-path validation lets a soft-accepting current provider swallow the name. - Matching is exact (case-insensitive) against explicitly-declared model collections only (`models`, `model`, `default_model`) — never fuzzy/family. - Same-provider declarer → keep current provider (canonicalize the id). - Multiple declarers → fail clearly and ask for `--provider <slug>`. - Single declarer → route there; for `providers.<slug>` user providers, set `explicit_provider` so the credential block resolves base_url/key from config. - Step e (`detect_provider_for_model`) is gated off when `config_routed`. The deliberately-supported openai-codex / xai-oauth hidden-model soft-accept (#16172 / #19729) is left untouched: when nothing in config matches, detection is a no-op. Salvaged from #45442 by harjothkhara (authorship preserved). Tests: tests/hermes_cli/test_model_switch_configured_provider_routing.py (7 tests). Full model_switch suite: 214 passed. Fixes #45006 |
||
|
|
fad4b40d9d |
fix(model): persist /model switch by default across sessions
A plain /model <name> switch only lasted for the current session — every new session reverted to the previously-configured model, so users had to re-switch every time (e.g. glm-5.1 -> glm-5.2 on every launch). Persist-by-default is now the behavior across all three /model surfaces (CLI, gateway, TUI/dashboard), gated by a new config key model.persist_switch_by_default (default true): /model <name> switch model (persists to config.yaml) /model <name> --session switch for this session only /model <name> --global switch and persist (explicit, unchanged) The effective persistence is resolved once via resolve_persist_behavior() in hermes_cli/model_switch.py so --session opts out, --global opts in, and the config-gated default applies otherwise. --global remains a valid explicit no-op alias for the new default. |
||
|
|
620fd59b8e
|
feat(model-picker): add Refresh Models control to bust stale model cache (#48691)
The desktop model picker had no way to force a fresh model fetch: model.options went through the 1h-cached provider_models_cache.json, and there was no flag to bust it. When a provider's cached list expired and its next live fetch failed, the picker fell back to the curated static list — silently dropping live-only models (e.g. OpenCode Zen's free tier like deepseek-v4-flash-free) the user had been using. - Thread refresh through model.options (RPC + REST /api/model/options) -> build_models_payload -> list_authenticated_providers, which calls clear_provider_models_cache() up front when set so every row re-fetches live. - Add a 'Refresh Models' control to the desktop picker (5-locale i18n, spinning sync icon). Normal opens leave refresh=false to stay snappy on the cache. Verified: stale cache hides deepseek-v4-flash-free -> refresh busts it -> live re-fetch surfaces it. refresh=false never touches the cache. |
||
|
|
3042045540 |
fix(picker): keep max_models=0 distinct from unlimited; lock cap semantics
Follow-up to the cap-removal salvage. The contributor guarded the new unlimited default with `[:max_models] if max_models else ...`, which conflates max_models=0 (used by slug-only callers that want an empty model list) with None (unlimited). Tighten to `is not None` at all five slicing sites in list_authenticated_providers / list_picker_providers, and add a regression test asserting the three-way contract: None=full, 0=empty, N=first N. |
||
|
|
9705e7944a |
fix(picker): remove max_models=50 cap in interactive model pickers
The interactive model pickers (Desktop REST API, TUI model.options, CLI /model) were hard-capped at max_models=50, which truncated large provider catalogs like Kilo Gateway (336 models) to just 50 entries. This made most models undiscoverable via the picker search box. Changes: - Change build_models_payload() default from max_models=50 to None (unlimited) - Change list_authenticated_providers() default from max_models=8 to None - Change list_picker_providers() default from max_models=8 to None - Fix all [:max_models] slicing to handle None as 'no limit' - Remove max_models=50 from 5 interactive picker callers: * web_server.py: get_model_options (Desktop /api/model/options) * web_server.py: get_recommended_default_model * model_switch.py: prewarm_picker_cache_async * tui_gateway/server.py: model.options JSON-RPC * cli.py: HermesCLI model picker - Telegram/Discord inline keyboard picker (gateway/slash_commands.py) still passes max_models=50 explicitly — unchanged behavior. The total_models field was already in the response payload and is now meaningful since models.length == total_models for interactive pickers. Fixes #48279 |
||
|
|
1039e90b5e |
fix(model-switch): probe /v1/models for providers without api_key
Section 3 of list_authenticated_providers (user-defined endpoints from the providers: config section) required an api_key before probing the endpoint's /v1/models for live model discovery. This broke local self-hosted backends (llama.cpp, Ollama, vLLM, etc.) that don't require authentication — they would only ever show the single default_model from config instead of the full model catalog. Section 4 (custom_providers list) already handled this correctly with the policy: probe when api_key is set OR when no explicit models are configured. Apply the same logic to Section 3 so local backends get full model discovery without requiring a placeholder api_key workaround. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
74c5158b10
|
fix(model): show bare custom endpoints in gateway picker (#45597)
Surface direct model.provider=custom endpoints in /model picker output and keep explicit bare custom switches on the current endpoint instead of requiring a named providers/custom_providers row. |
||
|
|
44c0c2d4ac |
refactor(inventory): make force_fresh_nous_tier keyword-only + pin contract
Some checks failed
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix Lockfile Fix / auto-fix-main (push) Waiting to run
Nix Lockfile Fix / fix (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
Tests / test (1) (push) Waiting to run
Tests / test (2) (push) Waiting to run
Tests / test (3) (push) Waiting to run
Tests / test (4) (push) Waiting to run
Tests / test (5) (push) Waiting to run
Tests / test (6) (push) Waiting to run
Tests / save-durations (push) Blocked by required conditions
Tests / e2e (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Has been cancelled
uv.lock check / uv lock --check (push) Has been cancelled
Follow-up to the salvaged perf fix. The new force_fresh_nous_tier param was inserted into list_authenticated_providers between custom_providers and max_models. Make it keyword-only (*) so a positional caller passing max_models as the 5th arg can never silently mis-bind it to the tier-refresh flag, and add a signature-contract test that fails if the keyword-only separator is later dropped. All in-repo callers already use keyword args; verified no caller breaks. |
||
|
|
eb70ab894b | fix(inventory): avoid fresh Nous tier checks in picker payloads | ||
|
|
4b2d00f845 |
feat(model_switch): honor discover_models in custom_providers section 4
Section 3 (user `providers:`) already honors `discover_models: false` to
skip live /models discovery and keep the explicit `models:` list. Section 4
(`custom_providers:` list) did not — `should_probe` ignored the field, so any
grouped custom provider with an api_key always had its configured subset
replaced by the full live /models catalog.
This adds the same `discover_models` support to section 4:
- Default True — no behaviour change for existing configs.
- `discover_models: false` keeps the explicit `models:` list even when an
api_key is present.
- String values ("false"/"no"/"0") are normalised to False, matching
section 3.
- If any entry in a grouped endpoint opts out, the whole group opts out.
Use case: endpoints that expose a full aggregator catalog via /models but
only serve a configured subset.
Salvaged from #29810 — rebased onto current main. The PR's other change
(`key_env` resolution in section 4) landed independently in commit
|
||
|
|
9ca11b35d5
|
perf(/model): prewarm picker provider-models cache in background (#39847)
* fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * perf(/model): prewarm picker provider-models cache in background The no-args /model picker calls list_authenticated_providers(), which fetches each authenticated provider's live /v1/models list serially. On a cold or stale (>1h TTL) cache that blocks ~1.5s on the user's critical path the first time /model is opened in a session. Warm that exact path off-thread during the idle window right after the CLI banner is shown: a once-per-process daemon thread runs list_authenticated_providers() to populate provider_models_cache.json for every authed provider. By the time the user types /model, the picker hits the warm disk cache (~136ms vs ~1500ms). Process-level Event guard (mirrors run_agent's _openrouter_prewarm_done) ensures at most one thread per process; fully exception-isolated so an offline/no-creds provider can never affect the session. |
||
|
|
21f55af769
|
fix(model-picker): stop routing OpenAI selection to OpenRouter (#37175)
The /model picker emitted a standalone slug=openai row (gated on
OPENAI_API_KEY). Selecting it ran resolve_provider_full("openai"),
which resolved the legacy providers.py alias openai->openrouter BEFORE
checking the user's own providers.openai config — silently switching
users onto OpenRouter (HTTP 401 when they have no OR key).
- model_switch.list_authenticated_providers: skip vendor names that are
aliases to an aggregator (isolates openai->openrouter; copilot/kimi/etc.
are real providers and unaffected). Kills the phantom picker row.
- providers.resolve_provider_full: user-config providers.<name> now wins
over the built-in alias table, so providers.openai (api.openai.com)
beats the alias.
- model_switch PATH A: user-config providers resolve credentials via
their own endpoint instead of the name-based runtime resolver that
doesn't know user-config slugs; plus a fail-loud guard for explicit
unauthed-aggregator hops.
Verified E2E with the reporter's config (no OR key): selecting OpenAI +
gpt-4o-mini now resolves to api.openai.com instead of openrouter.ai.
|
||
|
|
51c68d4ab1
|
Add Hermes desktop app (#20059)
* feat: better composer etc * docs: add desktop and dashboard run instructions * fix(desktop): address security scan findings * fix(dashboard): resolve @nous-research/ui path under npm workspaces The sync-assets prebuild step shelled out to 'cp -r node_modules/@nous-research/ui/dist/fonts ...' with a path relative to apps/dashboard/. That works only when the dep is installed locally in the dashboard workspace, but 'npm install' at the repo root (the documented setup — see apps/desktop/README.md) hoists shared deps to the root node_modules under npm workspaces. The relative cp then fails with 'No such file or directory', sync-assets exits 1, the Vite build aborts, and 'hermes dashboard' surfaces a generic 'Web UI build failed' message. Replace the shell one-liner with scripts/sync-assets.cjs, which walks up from the dashboard directory looking for node_modules/ @nous-research/ui — working in both the hoisted (workspaces) and co-located (standalone) layouts. Also guards against a missing dist/fonts or dist/assets with a clearer error pointing at a rebuild of the UI package rather than silently copying nothing. * feat(desktop): support connecting to a remote Hermes backend Add HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN env vars that, when set, short-circuit the local-child spawn in startHermes() and connect the Electron renderer to an already- running 'hermes dashboard' server reachable over the network. Motivating use case: WSL2 users who want to run the Hermes core (agent loop, tools, filesystem access) inside their WSL distribution while rendering the Electron GUI on native Windows. Before this change, the desktop app always spawned a local Python child on the same host as the renderer, which doesn't cross the WSL/Windows boundary. The remote path reuses waitForHermes() as a liveness probe (/api/status is in the backend's public endpoint allowlist), so the connection is only returned once the backend is actually ready. WebSocket URL derivation picks ws:// or wss:// based on the input scheme. URL validation rejects non-http(s) schemes and requires both env vars together to avoid a half-configured connection that would silently fall through to the spawn path. No behaviour change when the env vars are unset — the default local-spawn flow is untouched. Typical usage: # in WSL2 hermes dashboard --tui --no-open --host 0.0.0.0 --port 9119 --insecure # on Windows set HERMES_DESKTOP_REMOTE_URL=http://localhost:9119 set HERMES_DESKTOP_REMOTE_TOKEN=<session token> set HERMES_DESKTOP_IGNORE_EXISTING=1 (launch Hermes desktop) * ci(desktop): automate desktop releases Add GitHub Actions release channels for signed desktop installers and document the stable/nightly download paths. * feat: file tabs * refactor(desktop): tighten right-rail tab close API Promote closeRightRailTab/closeActiveRightRailTab as the single public entry point. Drops the activeTabRef + handleCloseDocument indirection in ChatPreviewRail, the unused $rightRailHasContent atom, and the legacy dismissFilePreviewTarget alias. -70 LOC. * feat(desktop): polish composer pill toward reference look Solid foreground-on-background send/voice-conversation circle (black-on-white in light, white-on-black in dark) anchors the right edge as the primary CTA instead of the orange theme primary. Bumps the primary control to 2.125rem so it visually outranks the ghost mic/plus controls. Opens up the surface padding (0.625rem x / 0.5rem y) so the input row breathes around its controls, and nudges the corner radius from 20 to 24px for a slightly pill-ier silhouette. LiquidGlass distortion is preserved. * feat(desktop): add startup and onboarding flow Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours. * fix(desktop): gate prompts on provider setup Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors. * fix(desktop): surface provider onboarding from session warnings Propagate credential warnings through session runtime info and open desktop onboarding whenever a session reports no usable provider, so unconfigured installs cannot fall through to prompt errors. * fix(desktop): route gateway provider errors to onboarding The "No inference provider configured" auth error reaches the renderer through gateway error events, not the prompt.submit promise; the previous patch only caught the latter, so the error toast still surfaced and onboarding never opened. Also strip credential-shaped env vars from the test:desktop:fresh sandbox so the packaged backend can't see provider keys leaking from the launching shell. * fix(desktop): use strict runtime check to drive onboarding setup.status returned True whenever any provider auth state was discoverable, including indirect fallbacks like a gh-CLI Copilot token. That made desktop think the user was set up while the agent's actual resolve_runtime_provider call still raised AuthError, leaving the user with a useless toast and no onboarding. Add a setup.runtime_check gateway method that runs the same resolver the agent uses on session creation, and switch the desktop onboarding overlay and prompt precheck to use it. * feat(desktop): OAuth-first onboarding using existing dashboard provider API Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message. * fix(desktop): polish onboarding provider list Reorder OAuth providers so Nous Portal is first, give the segmented Sign in / API key control equal column widths, and replace the engineer-flavored backend names like "Anthropic (Claude API)" / "MiniMax (OAuth)" with friendlier in-app titles. External-CLI providers now show a softer subtitle and an external-link icon instead of a chevron. * refactor(desktop): split onboarding overlay into store + view Move the OAuth state machine, runtime check, copy-to-clipboard, and api-key save into store/onboarding.ts (matching the boot.ts pattern), leaving the overlay as a presentation layer that subscribes via useStore. Tabs are now table-driven, child panels read flow from the store instead of prop-drilling, and the polling/PKCE/error/success branches share a small Status atom. * fix(desktop): external CLI providers + center mode tabs External-CLI providers (Claude Code, Qwen Code) now open an in-overlay panel with the CLI command, copy button, and an "I've signed in" recheck instead of firing an invisible toast. Center the Sign in / API key tab control so it sits under the heading instead of hugging the left edge. * fix(desktop): drop onboarding tabs for an inline link, group device-code waiting state Replace the Sign in / API key tab pair with an "I have an API key" footer link under the OAuth provider list, with a "Back to sign in" affordance inside the API key form. Group the device-code "Waiting for you to authorize..." status next to the Cancel button so the alignment matches the action. * refactor(desktop): tighten onboarding store + overlay Drop the dead isOnboardingBusy/BUSY set, factor the catch-fallback dance into safeReq, and share a single reloadAndConnect helper between PKCE submit, device-code success, external recheck, and api-key save. In the overlay, extract Step / CodeBlock / FlowFooter / CancelBtn / DocsLink atoms so the four sign-in panels share the same chrome instead of repeating it inline. Net effect: fewer literal divs, one place to touch the spacing, and the code-block + footer rows are reusable across future flows. * fix(desktop): mount onboarding from frame 1 to kill the FOUT Default onboarding.configured to null (unknown until the runtime check resolves) and have the onboarding overlay render whenever it's not yet confirmed true. The boot overlay now yields to it, so the very first paint is the Welcome card with a "While we get you set up..." progress strip instead of a flash of the chat shell between boot dismiss and onboarding mount. The picker swaps in cleanly once the gateway opens and the runtime check confirms the user is not configured. Already-configured users see the same prep card briefly while their existing runtime warms up, then the overlay dismisses without touching the chat shell. * fix(desktop): top-align empty sessions placeholder The "Start a chat to build your history." empty state used a min-h-35 grid place-items-center container, which floated the text in a tall dead zone. Render it as a flat paragraph that sits right under the section header like the empty pinned state does. * refactor(desktop): drop dead boot overlay Onboarding overlay subsumes the boot card now that it mounts from frame 1 and renders boot progress inline. The standalone DesktopBootOverlay is unreachable in every flow (yields whenever onboarding has not confirmed configured, dismisses once it has). * fix(desktop): hide pinned/recents sections until first session A fresh sidebar showed the Pinned and Recent chats headers with floating empty-state copy underneath. Drop both sections (and the now-orphan SidebarEmptySessionState) when there are no sessions yet — they reappear after the first chat. Skeletons during initial load are unchanged. * feat(gui): route embedded TUI through dashboard gateway (#21979) Inject HERMES_TUI_GATEWAY_URL into dashboard PTY sessions so embedded ui-tui instances attach to the in-process websocket gateway, with coverage for the new env wiring. * Add desktop remote gateway settings Make the desktop gateway connection configurable from settings so local remains the default while remote backends can be saved, tested, and applied without environment variables. * feat(gui): first-class Messaging page + gateway menu redesign - Add Messaging page to the desktop app with per-platform setup, status, and inline guidance. Catalog derives from gateway.config Platform enum + plugin registry, so every messaging adapter the CLI supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp, Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up without per-platform code. - New REST endpoints: GET /api/messaging/platforms, PUT and POST /test on the same path. Secrets go through the existing .env pipeline; enable/disable writes config.yaml. - Replace gateway statusbar dropdown with a richer panel: status row, icon-only restart + system-panel actions, recent activity (with timestamps trimmed in display, full text on hover), platform list. - Auto-poll the messaging page every 6s (paused when hidden) so status updates without a manual check. - Drop Settings / Command Center from the sidebar nav (still reachable via shortcuts and the titlebar cog). - Flatten top corners on Messaging/Skills/Artifacts/Chat panes. - Share new StatusDot component across messaging + gateway menu. - Fix gateway/config.py so an explicit platforms.<name>.enabled=false in config.yaml is honored when env tokens are present. - pb-9 on the chat content area for breathing room above the composer. * Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * pin electron version * hide application menu on non-mac systems * interpret compactPreview for non-string vlaues as JSON or an empty string * fix(desktop): keep composer contenteditable mounted across stacked toggle The composer rendered {input} inside two different parent fragments depending on `stacked`. When auto-expand flipped `stacked` (e.g. the moment typed text wrapped past two lines), React reconciled the two branches as different positions and unmounted/remounted the contenteditable. The fresh mount started empty, so any in-flight characters — most reliably reproduced by holding a key — were lost. Replace the conditional with a single CSS Grid whose template-areas swap on `stacked`. The three children (menu, input, controls) keep stable identities across the toggle; only their grid placement changes, which the browser handles without React tearing down the editor. * refactor(desktop): align install layout with install.ps1 / install.sh Make the desktop app's runtime layout match what scripts/install.ps1 and scripts/install.sh produce, so a desktop-only user and a CLI-only user end up with the same files in the same places and can share one install. Layout - ACTIVE_HERMES_ROOT = HERMES_HOME/hermes-agent (was: process.resourcesPath/hermes-agent, read-only) - VENV_ROOT = HERMES_HOME/hermes-agent/venv (was: userData/hermes-runtime) - desktop.log = HERMES_HOME/logs/desktop.log (was: userData/desktop.log) - HERMES_HOME default: %LOCALAPPDATA%\hermes on Windows, ~/.hermes elsewhere The packaged .app/.exe still ships a read-only payload at process.resourcesPath/hermes-agent (FACTORY_HERMES_ROOT). On first launch or after an installer-driven upgrade we sync factory -> active, then provision the venv and run pip install -e . against the active root. Key behaviors - Pin HERMES_HOME in the spawned Python's env so get_hermes_home() resolves to the same path resolveHermesHome() picked. Without this, Python falls back to ~/.hermes on every platform - fine on mac/linux, a split-state bug on Windows where our default is %LOCALAPPDATA%\hermes. - Detect developer installs by .git presence at ACTIVE; never overwrite a user's checkout via factory sync. - Marker at ACTIVE/.hermes-desktop-runtime.json (schema v4) tracks pyproject hash + factory version + runtime schema version. depsFresh fast-paths when nothing changed. - Dev (npm run dev) prefers SOURCE_REPO_ROOT over ACTIVE so devs run their local edits, not whatever's under HERMES_HOME. - Better error messages distinguish "no payload" from "no Python". - Preserve a legacy ~/.hermes on Windows when no %LOCALAPPDATA%\hermes exists, so users with prior pip/manual installs aren't orphaned. pyproject.toml - Promote fastapi, uvicorn[standard], ptyprocess (non-Windows), and pywinpty (Windows) to main dependencies. The dashboard backend (hermes dashboard) needs them at runtime; the previous lazy-import fallback was a footgun for fresh installs. - Empty the [pty] optional-extra; kept as a no-op back-compat alias for any existing pip install hermes-agent[pty] invocations. Drops the hardcoded BUNDLED_RUNTIME_REQUIREMENTS list in main.cjs - the desktop now installs whatever pyproject.toml says, single source of truth. Files - apps/desktop/electron/main.cjs: runtime layout, HERMES_HOME pin, factory->active sync, marker v4 - apps/desktop/scripts/test-desktop.mjs: track new venv location - apps/desktop/README.md: new Setup, Runtime Bootstrap, and Debugging sections - pyproject.toml: fastapi/uvicorn/pty backends in main dependencies; [pty] extra emptied Tested locally on Windows: npm run dev boots cleanly, sessions land at the new location, type-check + lint + test:desktop:platforms all pass. Verified end-to-end on a fresh Win11 VM via dist:win installer. Known gaps (filed as follow-ups, not in this PR): - Skills not seeded on packaged installs (sync_skills only runs in cmd_chat, not cmd_dashboard). Need to move to shared pre-dispatch. - Git Bash not bundled or detected; agent's terminal tool errors out with a useful message but desktop bootstrapper should pre-flight it. - install.ps1 / install.sh should be decomposed into composable phase libraries so the desktop bootstrapper can reuse them as a single source of truth across all install surfaces. * feat(desktop): theme polish, prose chat typography, composer chrome - DS tokens/midground, Backdrop, scoped scrollbars, typography plugin + prose - Composer liquid/radius utilities, thread font parity, tool/thinking cues - File tree label scale, preview flex, thread retry loading + streaming tests * feat(desktop): NSIS prereq detection page + auto-install via winget The packaged Windows installer now detects Python 3.11+ and Git for Windows at install time and offers to install missing prereqs via winget. Mirrors the prereq logic scripts/install.ps1 already runs for CLI installs, so desktop installer users get the same out-of-the-box experience as install.ps1 users. Why - Hermes' terminal tool calls bash.exe directly (tools/environments/ local.py); on Windows that's Git Bash from Git for Windows. Without it, the agent fails on the first terminal() call. - Hermes' Python runtime needs 3.11+. Without it, the desktop bootstrapper errors out at venv creation. - Both gaps surfaced on a fresh Windows 11 VM smoke test: VM had Python pre-installed but no Git, so the agent's first terminal call failed with "Git Bash isn't installed." - install.ps1 has had Install-Git + Install-Uv functions for ages. The desktop installer was the asymmetric outlier. How — NSIS prereq page - New file: apps/desktop/installer/prereq-check.nsh (plugged into electron-builder via build.nsis.include) - Real Wizard page using nsDialogs, inserted via customPageAfterChangeDir hook (between the Directory page and InstFiles). - Group boxes for Python and Git, each showing detection status. - Pre-checked install checkboxes when winget is available. - Auto-skips silently if both prereqs are already installed. - Falls back to manual download URLs when winget itself is missing. - Detection: - Python: probes `py -3.11`/`-3.12`/`-3.13`/`-3.14` via the Python launcher. Microsoft Store "Python stub" (no py.exe) is correctly classified as not-installed. - Git: `where git`. - winget: `where winget` (Win10 1809+ / Win11 with App Installer). - Install execution (in customInstall macro): - Python: nsExec::ExecToLog with `--scope user --silent`. Per-user install, no UAC prompt, output streams to install log. - Git: ExecShellWait via Windows ShellExecute. Critical because Git always installs per-machine and triggers UAC; ShellExecute preserves the foreground focus chain across non-elevated → elevated process spawns, so UAC actually comes to the foreground. nsExec::ExecToLog breaks the chain because winget runs hidden. - Both pass `--disable-interactivity --accept-package-agreements --accept-source-agreements` to suppress winget's own dialogs. - Verification: probes Git's standard install locations via FileExists rather than `where git`. NSIS's process inherits PATH at startup, so a freshly-installed Git won't be visible to `where` until restart. - Silent installs (/S) skip the prompts; managed deploys handle prereqs out-of-band via Group Policy / Intune. How — Electron-side safety net - New findGitBash() in main.cjs, parallel to findSystemPython(). Probes the same locations as tools/environments/local.py:_find_bash() so a positive result here means the agent's terminal tool will work. - ensureRuntime now throws a clear, actionable error on Windows when Git Bash isn't found, matching the existing "Python 3.11+ is required" error path. - Catches users the NSIS page doesn't: .msi installer users (NSIS prereq page doesn't run for MSI), `npm run dev` users, manual installers, anyone who unchecked the install boxes on the NSIS prereq page. - All gated on `IS_WINDOWS`; macOS / Linux unaffected. NSIS build issue (resolved) - electron-builder defaults to `-WX` (warnings as errors). NSIS optimizer emits "warning 6010: function not referenced" for our page functions because Page custom directives don't count as references in its static-analysis pass. The functions ARE called at runtime when NSIS invokes the page; the optimizer just can't see it statically. - Set `build.nsis.warningsAsErrors=false` in package.json so this spurious warning doesn't fail the build. (Documented option from electron-builder's nsisOptions.) Out of scope (filed for future work) - MSI prereq detection: Windows Installer custom actions are a different mechanism. Enterprise deploys typically handle prereqs via GP/Intune. - Bundle PortableGit + python-build-standalone in extraResources for zero-network installs. ~80MB increase. - Mac / Linux GUI prereq flows (different installer formats; Xcode CLT covers most macOS prereqs already; Linux is per-distro hard). Files - apps/desktop/installer/prereq-check.nsh (new, ~290 lines NSIS) - apps/desktop/package.json (build.nsis.include + warningsAsErrors) - apps/desktop/electron/main.cjs (findGitBash + preflight) - apps/desktop/README.md (Runtime prerequisites section) Cross-platform impact - macOS / Linux builds (dist:mac, dist:mac:dmg, dist:mac:zip): nsis config is ignored entirely; .nsh is dormant. - npm run dev: .nsh dormant; main.cjs preflight gated on IS_WINDOWS. - scripts/install.ps1, scripts/install.sh: no reference to any new files; CLI install paths untouched. - Hermes CLI / dashboard / gateway: no reference; runtime untouched. - All checks: node --check on main.cjs and test-desktop.mjs pass; npm run test:desktop:platforms 4/4 passing; node --test green. Tested - npm run dist:win produces signed .exe and .msi without errors. - Fresh Win11 VM (Python pre-installed, no Git): prereq page renders, Python check shows detected, Git checkbox pre-checked. Click Next → Git installs via winget with UAC prompt in foreground. - After install completes, Hermes launches and the agent's terminal tool can run bash commands. Verified Git Bash is detected at `C:\Program Files\Git\bin\bash.exe` by ensureRuntime's preflight. * feat: theme changes, composer tweaks, in app update ux, finesse * fix(cli): seed bundled skills on dashboard + gateway entrypoints `sync_skills(quiet=True)` was only being called from inside `cmd_chat`, which meant `hermes dashboard` (the desktop GUI's backend) and `hermes gateway` (Telegram/Discord/Slack/etc daemons) never seeded the bundled skill library into ~/.hermes/skills/. This surfaced as "No skills found" in the desktop GUI's skills panel on fresh installs, despite the agent having access to the full bundled library when invoked via `hermes chat`. scripts/install.ps1 worked around it by running skills_sync.py as part of Copy-ConfigTemplates, but that's not part of the desktop installer's bootstrap chain. Fix - Extract the skills-sync block from cmd_chat into a module-level `_sync_bundled_skills_quietly()` helper. - Call the helper from cmd_chat (preserving existing behavior), cmd_dashboard (after the --status/--stop early-return paths and fastapi import check, so we don't run skills_sync on management commands or when deps aren't installed), and cmd_gateway. Why these three entrypoints - cmd_chat: the user's primary CLI entrypoint - cmd_dashboard: the desktop GUI's backend; this is what `hermes dashboard --tui` invokes when the desktop bootstrapper spawns Hermes - cmd_gateway: long-running daemons where the user expects the agent to have full skill access Other entrypoints (cmd_config, cmd_doctor, cmd_login, cmd_status, etc.) are management commands that don't need skill discovery and were never running skills_sync in the first place — leaving them alone. Idempotence - tools/skills_sync.py is manifest-based: skipped skills cost milliseconds. Calling it from multiple entrypoints adds no real cost, and users running `hermes chat` then `hermes dashboard` get two fast no-ops on the second call. Failure handling - Helper wraps skills_sync in try/except. Skills are an enhancement, not a hard dependency — Hermes runs fine with an empty skills/ dir. Files - hermes_cli/main.py: + new helper `_sync_bundled_skills_quietly()` at module level + cmd_chat: replace inline block with helper call + cmd_dashboard: add helper call after fastapi import succeeds + cmd_gateway: add helper call before delegating to gateway_command * feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes - Hoist todo to first-class widget (shadcn checkboxes, brand colors, no tool-accordion). Header derives label from active task; non-active rows fade. - Replace raw JSON dumps with structured key/value summaries via formatToolResultSummary; nested error extraction for clearer failures. - Fix loaded-session grouping: stitch interleaved assistant/tool iterations into one bubble instead of orphaned synthetic messages. - Stable tool/thinking timers via keyed registry so unmount/scroll doesn't reset elapsed counts; gate "running" on real live thread state. - Reorganize chat-only assistant-ui components under components/chat/. * fix(desktop): address CodeQL alerts on PR #20059 - settings/helpers.ts: harden setNested against prototype pollution. POLLUTING_PATH_PARTS check is now applied at every assignment site (loop + leaf) and uses Object.defineProperty so CodeQL can see the guard inline rather than via a helper function call. - lib/markdown-preprocess.ts: rebuild the dangling-fence close regex from a fence-char + length instead of marker.replace(...). The marker is captured by `(`{3,}|~{3,})` so it can only be backticks or tildes, but CodeQL was tracing tainted input text into the RegExp source and flagging hostname dots from input as part of the pattern (false positive js/incomplete-hostname-regexp on the test fixture URLs). Reconstructing from a literal char breaks the dataflow. - scripts/notarize-artifact.cjs: drop args from the run() rejection message. Args carry --key-id / --issuer / key file path; the existing outer catch already squashes errors to a generic line, but CodeQL was flagging the args.join(' ') as clear-text logging of APPLE_API_KEY_ID. Composer DOM-text-as-HTML alerts (composer/index.tsx:379, :547) are already addressed in |
||
|
|
aa283d1e4f | fix(model): isolate custom provider picker credentials | ||
|
|
dc235e93cb |
chore: remove dead code — 28 unused functions/classes across 16 files
Vulture + per-symbol verification (whole-repo grep incl. tests, string literals, getattr, decorator/registry/argparse dispatch) confirmed each of these has zero callers anywhere — not reachable via any dynamic-dispatch path, not referenced by tests, not re-exported. Removed: - acp_adapter/tools.py: _build_patch_mode_content - agent/anthropic_adapter.py: read_claude_managed_key (diagnostics-only, never called) - agent/bedrock_adapter.py: get_bedrock_model_ids - agent/browser_registry.py: get_active_browser_provider - agent/chat_completion_helpers.py: _take_request_client (x2 nested closures, never invoked) - gateway/platforms/weixin.py: _rewrite_headers_for_weixin, _rewrite_table_block_for_weixin - hermes_cli/banner.py: _skin_branding - hermes_cli/debug.py: _delete_hint - hermes_cli/gateway.py: _setup_email, _setup_sms, _setup_yuanbao (platform keys absent from the _builtin_setup_fn dispatch dict; handled by the _setup_standard_platform fallback) - hermes_cli/kanban_db.py: set_max_runtime, active_run - hermes_cli/kanban_diagnostics.py: severity_of_highest, _latest_clean_event_ts - hermes_cli/main.py: _build_provider_choices, cmd_portal (portal subcommand is wired via portal_cli.add_parser, not this wrapper) - hermes_cli/model_switch.py: CustomAutoResult (orphaned by the switch_model() extraction) - hermes_cli/models.py: format_model_pricing_table, fetch_nous_account_tier - hermes_cli/portal_cli.py: _nous_portal_base_url - hermes_cli/proxy/server.py: handle_models_fallback (defined but never registered on the router) - tools/computer_use/cua_backend.py: _parse_element, _is_arm_mac - tools/file_operations.py: _get_safe_write_root (prod uses the imported agent.file_safety.get_safe_write_root directly) - tools/skills_tool.py: _load_category_description Also dropped two imports left unused by the removals: - tools/file_operations.py: get_safe_write_root alias - tools/computer_use/cua_backend.py: import platform Pure deletion: -551 LOC. No behavior change. Test files covering the edited modules pass (640/640); the broader suite's pre-existing/env-dependent failures reproduce unchanged on origin/main. |
||
|
|
66827f8947 |
chore: prune unused imports and duplicate import redefinitions
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves |
||
|
|
3a9bc9d88a
|
fix(model picker): unify /model and hermes model lists, add disk cache (#33867)
* fix(model picker): unify /model and `hermes model` model lists, add disk cache
The /model slash picker and `hermes model` were drifting apart. /model
read the raw static `OPENROUTER_MODELS` list (31 entries, including 5
that fail at runtime — no tool-call support or absent from live catalog),
while `hermes model` ran the same list through the live OpenRouter
/v1/models tool-support filter and showed 26 valid entries. Same problem
existed for every other authed provider: /model used curated static
lists, `hermes model` used live /v1/models.
Unifies both surfaces on `provider_model_ids()` and adds a generic
disk-cached wrapper so the picker stays snappy.
Changes
- hermes_cli/models.py: new `cached_provider_model_ids()` —
~/.hermes/provider_models_cache.json, 1h TTL, per-provider entries
keyed by credential fingerprint (env vars + OAuth file mtimes).
Stale-data-beats-no-data on transient failures. Pair with
`clear_provider_models_cache(provider=None)`.
- hermes_cli/models.py: `provider_model_ids("nous")` now falls back
to the docs-hosted manifest (not the in-repo snapshot) when the live
Portal /models call fails — preserves the model_catalog regression
guarantee while still going through the unified pathway.
- hermes_cli/model_switch.py: `list_authenticated_providers` routes
sections 1, 2, and 2b through `cached_provider_model_ids(slug)` with
curated fallback when the live fetcher comes up empty.
- hermes_cli/model_switch.py: `parse_model_flags` extended to a
4-tuple, parses `--refresh`.
- cli.py / gateway/run.py / tui_gateway/server.py: updated unpacking;
CLI + gateway wire `--refresh` to `clear_provider_models_cache()`.
- hermes_cli/main.py: `hermes model --refresh` argparse flag.
- hermes_cli/commands.py: `/model` args_hint advertises `--refresh`.
- tests/hermes_cli/test_inventory.py: refresh stale comment.
Live PTY parity verification
- /model → OpenRouter row: `(26 models)` (was 31, with broken entries)
- `hermes model` → OpenRouter: 26 models (unchanged)
- The 5 dropped entries: `pareto-code` (no tool-call support),
`gemini-3-pro-image-preview` (no tool-call support),
`elephant-alpha`, `hy3-preview:free`, `ring-2.6-1t:free` (gone
from OpenRouter's live catalog).
Live PTY timing
- First /model open, empty cache: 4624 ms (full network round trip
across every authed provider)
- Second /model open, warm cache: 51 ms (90× faster)
- `/model --refresh` clears the disk cache and re-fetches.
Cache schema (~/.hermes/provider_models_cache.json, ~3 KB):
{ "anthropic": {"fp": "<sha256:16>", "at": 1748..., "models": [...]},
... }
Targeted tests: tests/hermes_cli/ + gateway model tests + tui_gateway —
5855/5855 pass.
* fix(model picker): use blake2b for cache fingerprint to silence CodeQL
py/weak-sensitive-data-hashing flagged the sha256 call in
_credential_fingerprint() as a high-severity alert because the input
includes env var values whose names contain *_API_KEY / *_TOKEN.
The hash is used solely as a cache-bust identity — never reversed, never
stored, collisions are harmless (worst case: cache miss → live re-fetch).
blake2b serves the same purpose and isn't flagged by this rule.
Functional behavior identical: 16-hex-char digest, cache hit/miss logic
unchanged. Live re-verified — 26 OpenRouter models, warm-cache 78ms.
|