Commit graph

16424 commits

Author SHA1 Message Date
Teknium
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.
2026-07-20 02:22:22 -07:00
liuhao1024
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 fad4b40d9 where /model switched to
persist-by-default, causing /model xxx --provider xxx to overwrite the
global config when the user only intended a temporary switch.

Fixes #58290
2026-07-20 02:22:22 -07:00
kshitijk4poor
98cadadd84 refactor: extract _build_partial_stream_stub helper
Deduplicates the SimpleNamespace stub-response construction that was
copy-pasted between the tool-call-drop guard and the text-only-drop
guard (both in interruptible_streaming_api_call).  The third site
(error-handler at ~L3775) has a structurally different shape (different
role/reasoning/model/usage sources + _content_filter_terminated tag)
and is left inline with an existing comment.
2026-07-20 13:31:02 +05:30
ajzrva-sys
65bb16c8ce fix(streaming): detect text-only stream drops with no finish_reason (#32086)
When a streaming response ends cleanly (HTTP 200) with no finish_reason
after delivering text but no tool calls, the chunk collector silently
stamps finish_reason='stop' and the conversation loop presents truncated
text as a complete response.

Three stream-drop paths now exist after chunk collection:

1. Zero-chunk → EmptyStreamError, retried (existing)
2. Tool-call in progress, no finish_reason → partial-stream-stub (existing)
3. Text-only, no finish_reason → partial-stream-stub (NEW — this fix)

Path 3 routes through the same PARTIAL_STREAM_STUB_ID + FINISH_REASON_LENGTH
machinery as path 2. The conversation loop shows 'Stream interrupted —
requesting continuation' and injects a continue prompt, giving the model
a chance to resume where the stream dropped.

Observed with DeepSeek provider where CloudFront drops SSE streams
mid-response after delivering partial text.
2026-07-20 13:31:02 +05:30
waroffchange
134c2ed8b3 docs(links): update moved Cloudflare Tunnel docs URL
developers.cloudflare.com/cloudflare-one/connections/connect-networks/
returns 301 Moved Permanently to
/cloudflare-one/networks/connectors/cloudflare-tunnel/ after Cloudflare
reorganized the Cloudflare One docs. The new page is the named-tunnel
workflow this paragraph points readers at.
2026-07-20 00:56:31 -07:00
kshitijk4poor
86e603e7d6 fix(compression): verify cached prompt embeds current memory before retaining
The salvaged retention check compared the built-in memory snapshot
before vs after the disk reload. That holds for a long-lived CLI agent,
but on fresh-agent surfaces (gateway per-turn agents, TUI) the cached
prompt is restored from the session DB and can predate mid-session
memory writes that the fresh MemoryStore already absorbed at init: the
snapshot is then identical on both sides of the reload while the prompt
itself is stale, so compression would retain (and re-persist via
update_system_prompt) a prompt missing the new memory for the life of
the session.

Replace the equality check with a containment check
(_cached_prompt_reflects_builtin_memory): retain the cached prompt only
when the freshly-reloaded rendered blocks appear verbatim inside it,
and rebuild when a leftover block header remains for a target whose
entries have since been emptied or disabled. Block headers are shared
via MEMORY_BLOCK_HEADERS in tools/memory_tool.py so the check stays in
lockstep with MemoryStore._render_block.

Adds regression guards for the gateway stale-restore path and the
emptied-memory leftover-block path; verified with a real-MemoryStore
E2E matrix (9 scenarios) against a temp HERMES_HOME.
2026-07-20 13:13:02 +05:30
konsisumer
54c3f589ad fix(compression): retain prompt cache when memory is unchanged 2026-07-20 13:13:02 +05:30
Teknium
463c2ae255 fix: section-3 grouping follow-ups for salvaged PR #36998
- extra_headers participates in the section-3 group identity (mirrors
  section 4 — header-routed tenants behind one proxy URL stay distinct)
- model declarations go through _declared_model_ids() so
  models: [{id: ...}] rows keep working
- gateway model-switch handler moved to gateway/slash_commands.py since
  the PR branched — re-applied the display-form edits there (both the
  legacy picker closure and the current typed path)
- regression tests: same-endpoint fold, api_mode separation,
  header-routed separation, list-of-dict models, RID display stripping
2026-07-20 00:41:21 -07:00
antydizajn
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.
2026-07-20 00:41:21 -07:00
antydizajn
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).
2026-07-20 00:41:21 -07:00
Teknium
2684e3077f chore: fix import ordering after cherry-pick conflict resolution 2026-07-20 00:41:11 -07:00
David Metcalfe
0831e5e326 fix(desktop): preserve collapsed-provider set across profile switches
The collapsed-providers atom (`hermes.desktop.collapsed-providers`) is a
global presentation-layer preference, but the catalog the picker renders is
profile-scoped (`getGlobalModelOptions` routes through `profileScoped()`,
model-menu-panel.tsx:87-93). The previous code called `pruneStaleCollapsed`
on every render against `pickerProviders`, which silently deleted any
slug not present in the active catalog.

Bug class (review from @teknium1):
- Profile switch to a catalog that lacks a previously-collapsed provider
  → that collapse preference is permanently lost.
- Refresh Models that drops a provider (revoked key, plugin disabled,
  backend policy change) → same loss.
- Any transient empty catalog (loading → []) → guarded by `length > 0`,
  but still loses state once the new (smaller) catalog resolves.

Fix:
- Remove the prune `useEffect` from model-menu-panel.tsx.
- Delete `pruneStaleCollapsed` (no other caller; the AGENTS.md "no
  speculative infrastructure" rule applies — keeping it exported with a
  plausible-sounding docstring is a foot-gun for future contributors who
  would re-call it against a single active catalog and reintroduce the bug).
- Document on the atom why we deliberately don't prune: provider slugs
  come from a bounded configured set (not user input); the render loop
  only visits providers in the active `groups`; dead slugs have no
  observable effect (`collapsedProviders.includes(slug)` against an
  absent slug is a no-op).

Tests:
- `preserves the collapsed set across a profile switch whose catalog
  lacks the slug` — regression pin for the profile-switch case.
- `preserves the collapsed set when Refresh Models drops a provider` —
  regression pin for the refresh-models case.

Verified: `tsc -p . --noEmit` is clean on the changed files. CI is the
source of truth for the vitest run (the local React-detection env
failure pre-dates this PR; CI passes).

Closes the review thread on #64690.
2026-07-20 00:41:11 -07:00
David Metcalfe
f52b6530ff feat(desktop): collapsible provider groups in model picker
Click a provider header (DEEPSEEK, GOOGLE, etc.) in the model picker dropdown
to collapse/expand its model list. State persists across sessions via localStorage.

- New provider-collapse store with persistentAtom<string[]> + stale-key pruning
- Provider headers become clickable DropdownMenuItems with chevron indicators
- textValue="" excludes headers from Radix typeahead (both reviewers flagged)
- Auto-expands the active provider so the checkmark is always visible
- Search bypasses collapse (typing shows all matching models)
- Keyboard accessible via Enter/Space on the header row
- Store double-read eliminated per cross-vendor review feedback

Reviewed-by: Flash + GPT-OSS cross-vendor review (all SHOULD-FIXs addressed)

Related: #60966 (different interaction model — hover-to-expand)
2026-07-20 00:41:11 -07:00
Teknium
da519ebc5c fix(dashboard): fold one-field mcp category into agent tab
The new top-level mcp: config section surfaces exactly one field
(auto_reload_on_config_change) in the dashboard settings schema, which
tripped the no-single-field-categories invariant. Merge it into the
agent tab like onboarding/computer_use.
2026-07-20 00:41:05 -07:00
Teknium
60092f728c fix(mcp): move auto-reload opt-out to top-level mcp: section + regression tests
Follow-up on the salvaged #67449: auxiliary.mcp is the side-LLM task
provider block (provider/model/timeout for MCP aux calls) — a watcher
behavior toggle doesn't belong there. Move it to a new top-level mcp:
runtime section and read it from the same freshly-parsed config.yaml the
watcher already diffs (no second load_config() per tick, and flipping the
toggle + editing mcp_servers in one edit behaves correctly).

Also adds a regression test for the salvaged #55701 false-positive fix:
${VAR} templates in mcp_servers made the raw-yaml-vs-expanded-snapshot
comparison permanently unequal, so ANY save_config_value() rewrite (e.g.
/reasoning changing agent.reasoning_effort) fired a full MCP reconnect.

Credits: @OYLFLMH (#55701 env-expand fix), @TurgutKural (#67449 opt-out).
2026-07-20 00:41:05 -07:00
Turgut Kural
1abcccdeba fix(mcp): read opt-out toggle from auxiliary.mcp, not top-level mcp
The opt-out default was declared in DEFAULT_CONFIG["auxiliary"]["mcp"][...]
but the watcher in _check_config_mcp_changes() read top-level
load_config().get("mcp") — a key that does not exist in the loaded
config shape. Consequently the declared default was never observed and
the fallback stayed True at runtime: setting auto_reload_on_config_change
to false in config.yaml silently did nothing.

Resolve through the same path the default is declared on:
  cfg["auxiliary"]["mcp"]["auto_reload_on_config_change"]

Tests:
- test_optout_disables_auto_reload: mocked config now mirrors the real
  DEFAULT_CONFIG shape (auxiliary.mcp), so the test exercises the actual
  lookup path instead of a separately mocked shape.
- test_optout_path_is_auxiliary_mcp_not_top_level: regression guard — a
  config that sets ONLY top-level mcp.auto_reload_on_config_change=false
  must NOT disable the reload. This pins the config-path contract so a
  future regression to _cfg.get("mcp") is caught.

Addresses sweeper review: the declared default was never observed at
runtime because the watcher read a different config path than the one
where the default was defined.

Co-authored-by: Turgut Kural <turgut.kural@gmail.com>
2026-07-20 00:41:05 -07:00
Turgut Kural
5c2d098bb0 feat(mcp): add opt-out for automatic MCP reload on config change (cache-safe)
The automatic MCP reload added in #1474 watches config.yaml's mcp_servers
section every 5s and reloads on any change. Every reload rebuilds the agent
tool surface and INVALIDATES the provider prompt cache — the next message
re-sends the full input prefix, which is expensive on long-context /
high-reasoning models. When config.yaml is rewritten frequently (external
tooling, multiple Hermes instances, or a flapping MCP server that rewrites
config), this causes silent, repeated cache-breaking reloads.

Add `mcp.auto_reload_on_config_change` (default: true, backward compatible).
When set to false:
- The config change is still DETECTED (watcher keeps running).
- No automatic reload happens.
- The user is told the config changed, that new settings are NOT yet
  applied, and how to apply them on their own terms with /reload-mcp —
  including the explicit warning that /reload-mcp invalidates the prompt
  cache.

Manual /reload-mcp is unaffected and still works for users who want to
apply changes deliberately.

Tests: extend TestMCPConfigWatch with test_optout_disables_auto_reload.

Co-authored-by: Turgut Kural <turgut.kural@gmail.com>
2026-07-20 00:41:05 -07:00
OYLFLMH
f46ae96963 fix(cli): expand env vars in mcp_servers config watcher comparison
_check_config_mcp_changes compared mcp_servers from two inconsistent
sources:
- init: self.config.get('mcp_servers') -> from load_config() + _expand_env_vars -> expanded values
- watcher: yaml.safe_load(cfg_path) -> raw  templates

When mcp_servers uses env-var templates like ${POWERMEM_API_KEY},
every save_config_value() that rewrites config.yaml (even for unrelated
keys) triggers a false-positive MCP reload, reconnecting all servers.

Apply _expand_env_vars() to the raw watcher value before comparison
so both sides use the same expanded representation.

Test plan: tests/cli/test_cli_mcp_config_watch.py (6/6 pass)
2026-07-20 00:41:05 -07:00
Teknium
d46b3bdeb4 chore: map salvage contributors 2026-07-20 00:41:01 -07:00
Teknium
ad86b8f469 fix: add qwen3.7-plus to alibaba list + qwen3-max context fallback
Follow-up to the salvaged #66083/#42792 commits:
- alibaba (Qwen Cloud coding-intl) gets qwen3.7-plus too — same platform
  allowlist as alibaba-coding-plan (issue #44662 comment by @coder-movers)
- qwen3-max substring context entry (262144) so the newly-listed
  qwen3-max-2026-01-23 snapshot doesn't fall to the generic 131072 qwen
  fallback
2026-07-20 00:41:01 -07:00
asscan
e0a27690d3 fix: add qwen3.7-plus context length (1M)
Add qwen3.7-plus to DEFAULT_CONTEXT_LENGTHS with 1M context window.
Without this entry, the model falls back to the generic 'qwen' entry
(128K), causing premature context compression at 50% (64K tokens)
instead of the correct 500K threshold.

Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
2026-07-20 00:41:01 -07:00
joezhang
772c232631 fix(providers): update alibaba coding plan supported model list
The model list for alibaba coding plan is currently out of sync with
the actually supported models. See the official documentation[1].

Per the docs, alibaba coding plan does not support qwen3.7-max;
it supports qwen3.7-plus instead. Additionally, qwen3-max-2026-01-23
was missing from the model list.

Changes to the alibaba-coding-plan model list:

- Replace qwen3.7-max with qwen3.7-plus
- Add qwen3-max-2026-01-23

[1] https://www.alibabacloud.com/help/en/model-studio/coding-plan
2026-07-20 00:41:01 -07:00
Teknium
9fc35e0a31 chore: add contributor email mapping for Dhravya 2026-07-20 00:40:40 -07:00
RenoMG
95c616be20 fix(supermemory): complete self-hosted endpoint routing 2026-07-20 00:40:40 -07:00
Dhravya Shah
24ac26a3da feat(supermemory): support custom base URL for self-hosted servers
The supermemory SDK already honors SUPERMEMORY_BASE_URL, but the raw
urllib call used for session-end conversation ingest hardcoded
https://api.supermemory.ai/v4/conversations, so ingest always hit the
cloud even when pointing at a self-hosted server (e.g.
http://localhost:6767).

Resolve the base URL as config (supermemory.json base_url) >
SUPERMEMORY_BASE_URL env var > https://api.supermemory.ai, strip any
trailing slash, and use it for both the SDK client and the
/v4/conversations ingest endpoint.
2026-07-20 00:40:40 -07:00
kshitijk4poor
244dabbd9c test(cli): mock _cleanup_oneshot_runtime in all _run_and_exit tests
Phase 2c found 3 tests that called _run_and_exit_oneshot without
mocking _cleanup_oneshot_runtime, causing real cleanup (terminal,
browser, MCP, auxiliary) to run in the pytest worker. Add the mock
to all three for test isolation.

Also remove redundant 'import logging' inside _exit_after_oneshot
(already imported at module level, line 729).
2026-07-20 12:59:28 +05:30
kshitijk4poor
97fc8a4a3c refactor(cli): apply /simplify-code findings to oneshot teardown
- Add idempotency guard (_oneshot_cleanup_done) to _cleanup_oneshot_runtime,
  matching cli.py:_run_cleanup's pattern
- Trim _exit_after_oneshot docstring from 22 to 8 lines (per-resource
  ownership enumeration already documented in _run_agent)
- Add comment clarifying cleanup ordering mirrors gateway/run.py, not
  cli.py (oneshot has no _active_agent_ref)
- Clarify session_db.close() comment: agent.close() calls end_session()
  but leaves the connection open

Findings skipped (follow-up scope):
- Extract shared 5-step cleanup helper from cli.py:_run_cleanup (widens
  scope into critical file)
- Extract _hard_exit helper (3 copies across cli.py)
- Extract shutdown_agent_resources helper (touches run_agent.py + cli.py
  + gateway/run.py)
- Test boilerplate dedup (test-only, non-blocking)
2026-07-20 12:59:28 +05:30
kshitijk4poor
2de60a3a7e fix(cli): expand oneshot cleanup to cover all process-global resources
The initial salvage from #43698 only shut down MCP servers and cached
auxiliary clients. The interactive CLI's _run_cleanup() also closes
terminal environments, browser sessions, and interrupts async
delegations — all of which can hold native-extension-backed resources
(aiohttp connectors, websocket clients) that SIGABRT during
Py_FinalizeEx.

Add the missing three sites to _cleanup_oneshot_runtime(), matching the
order in cli.py:_run_cleanup(). Update tests to cover the expanded
cleanup chain.

Credit: @konsisumer (#67768) identified the full cleanup surface.
2026-07-20 12:59:28 +05:30
harjoth
7462546a33 docs(cli): clarify oneshot hard-exit cleanup scope 2026-07-20 12:59:28 +05:30
harjoth
54eea80bf7 test(cli): cover termux oneshot usage file 2026-07-20 12:59:28 +05:30
harjoth
fbfe89871b fix(cli): guarantee hard exit after cleanup interruption 2026-07-20 12:59:28 +05:30
harjoth
b82ffdaa4d test(cli): preserve oneshot usage file through hard exit 2026-07-20 12:59:28 +05:30
harjoth
bfa7a794cb fix(cli): avoid one-shot SIGABRT during teardown 2026-07-20 12:59:28 +05:30
kshitijk4poor
113d9f63b5 fix: Windows guard, dedup recovery, profile-safe paths, clear SO_RCVTIMEO
Follow-up fixes for salvaged PR #67686 (issue #67639):

1. Windows regression: bare 'import fcntl' at module level in entry.py and
   slash_worker.py would crash on Windows (fcntl is POSIX-only). entry.py
   explicitly aims to 'import cleanly on Windows'. Extracted all fcntl/socket
   logic to tui_gateway/_stdin_recovery.py with try/except import guards.

2. fd leak: socket.fromfd() dups fd 0; s.detach() returned the fd number
   without closing it, leaking one fd per recovery call. Changed to s.close()
   (safe — fromfd duped the fd, closing won't close stdin).

3. Code duplication: ~80 lines of recovery loop + diagnostic were copy-pasted
   between entry.py and slash_worker.py. Extracted to shared
   tui_gateway/_stdin_recovery.py (handle_spurious_eof + diagnose_stdin_state).

4. Profile-unsafe path: scanner used Path.home() / '.hermes' / 'plugins' but
   canonical plugin discovery uses get_hermes_home() / 'plugins'. With
   HERMES_HOME pointing elsewhere, scanner missed the actual plugin dir.
   Also gated project plugins behind HERMES_ENABLE_PROJECT_PLUGINS to match
   hermes_cli/plugins.py.

5. SO_RCVTIMEO not cleared: recovery only called os.set_blocking(0, True)
   but a child that set SO_RCVTIMEO would cause the next readline to time
   out and loop. Now clears SO_RCVTIMEO alongside O_NONBLOCK.
2026-07-20 12:48:50 +05:30
x7peeps
01c02c6f8f fix(tui): recover from spurious stdin EOF caused by child O_NONBLOCK flip
Fix #67639

根因分析:
子进程继承 fd 0 (stdin) 后设置 O_NONBLOCK 标志时,该标志作用于共享的
open file description 而非单个文件描述符。这导致 gateway 的下一次 read()
返回 EAGAIN,CPython 的缓冲层将其转换为 b'',表现为 EOF,gateway 因此
意外退出。这不是真正的 TUI 关闭管道,而是子进程修改了共享文件状态。

修复涉及三个 Gap:

Gap 1 — check_subprocess_stdin.py 扫描范围不足:
- 原正则仅匹配 subprocess.run/Popen,扩展到 call/check_output/check_call/
  os.system/asyncio.create_subprocess_exec/shell
- 新增扫描 ~/.hermes/plugins/ 和 ./.hermes/plugins/ 用户插件目录
  (hermes_cli/plugins.py:10-12 定义的插件加载路径)

Gap 2 — Gateway 无自愈能力:
- entry.py 和 slash_worker.py 的 stdin 循环改为 while True + readline()
  模式
- 当 readline() 返回空字符串时,检查 O_NONBLOCK 标志判断是否为虚假 EOF
- 虚假 EOF → 恢复 blocking 模式并继续; 真实 EOF → 正常退出
- 添加恢复频率限制 (10次/分钟),防止无限循环
- 添加 _diagnose_stdin_state() 诊断函数,记录 O_NONBLOCK/SO_RCVTIMEO 状态

Gap 3 — 日志消息误导:
- 原 "stdin EOF (TUI closed the command pipe)" 改为 "stdin EOF (peer closed)"
  或 "stdin spurious EOF (subprocess O_NONBLOCK flip)",附带诊断信息

附带修复: compute_host.py 的 subprocess.check_output 缺少 stdin= 参数
2026-07-20 12:48:50 +05:30
Teknium
39b30bacf7 docs(x_search): comment out reasoning_effort in sample config blocks
The copy-paste config sample had reasoning_effort: low active, which
would silently downshift effort for anyone pasting the block. Keep it
commented like other optional keys. Also add the contributor email
mapping for the salvage.
2026-07-19 23:58:33 -07:00
YAMAGUCHI Seiji
48adc1f602 test: cover X Search reasoning config propagation 2026-07-19 23:58:33 -07:00
YAMAGUCHI Seiji
5befa15aba feat: configure X Search reasoning effort 2026-07-19 23:58:33 -07:00
kshitijk4poor
0144743b21 chore: add contributor email mapping for kshitijk4poor 2026-07-20 12:13:37 +05:30
kshitij
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.
2026-07-20 12:13:37 +05:30
kyssta-exe 25470058+kyssta-exe@users.noreply.github.com
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.
2026-07-20 12:13:37 +05:30
Floze
cd0219da86 fix(gateway): stop slow restart redelivery loops 2026-07-20 12:11:07 +05:30
Teknium
693f935936
feat(picker): fold Qwen providers into one group row in provider pickers (#67758)
Consolidates the three Qwen provider slugs (alibaba / Qwen Cloud,
alibaba-coding-plan / Alibaba Cloud Coding Plan, qwen-oauth / Qwen CLI
OAuth) under a single 'Qwen' group row in the interactive provider
pickers, matching the existing OpenAI / Kimi / MiniMax / xAI groups.

Display-only via PROVIDER_GROUPS — slug identity, --provider, and
/model <provider:model> paths are unchanged. Because group_providers()
is the shared fold, the CLI 'hermes model' picker, the setup wizard,
and the Telegram /model keyboard all pick up the grouping with no
per-surface changes.
2026-07-19 23:34:03 -07:00
Teknium
0d7fad7b88
fix(config): shipped template no longer enables session auto-reset (#67772)
#60194 flipped SessionResetPolicy's default to mode: none, but
cli-config.yaml.example still shipped session_reset.mode: both. Every
install path (install.sh, install.ps1, docker stage2-hook, hermes
doctor) copies the template verbatim to ~/.hermes/config.yaml, so fresh
installs got an EXPLICIT mode: both that overrides the code default —
users hit 24h-idle resets with 'nothing' in their config enabling it.

- cli-config.yaml.example: session_reset.mode both -> none, comments
  rewritten to describe auto-reset as opt-in
- docs/session-lifecycle.md: appendix example updated to match
- tests/gateway/test_config.py: invariant tests — template seed, absent
  config, and mode-less session_reset block all resolve to mode none;
  explicit opt-in still honored
2026-07-19 23:33:53 -07:00
Drexuxux
1157c636c5 fix(compression): stop the progress floor from splitting a tool group
_find_tail_cut_by_tokens aligns cut_idx away from tool-call/result
boundaries (_align_boundary_backward), and both tail anchors re-align after
moving it. The final statement then raised the result to head_end + 1 so
compression always claims at least one message — without that floor the
caller's compress_start >= compress_end guard turns the pass into a no-op
that re-runs forever.

That raise discarded the alignment. When the floor landed inside a tool
group, the parent assistant(tool_calls) fell in the summarised region while
its tool results started the tail, and _sanitize_tool_pairs dropped those
orphans outright — so the tool output was neither summarised nor kept. It
vanished. That is exactly the silent loss _align_boundary_backward's own
docstring says the alignment exists to prevent.

Two back-to-back tool calls are enough to trigger it on default settings
(protect_first_n=3):

    system, assistant(call_1), tool, tool, assistant(call_2), tool

    aligned cut          = 4   (keeps call_2's group together)
    returned cut         = 5   (floor overrode it)
    summarised region    = [assistant(call_2)]
    tail                 = [tool(call_2)]  -> orphan -> dropped

Sweeping every well-formed block layout up to length 6 (21840 transcripts),
5623 of them — 26% — split a call/result pair this way.

Re-align FORWARD after applying the floor. Forward, never backward: pulling
back would hand return the message the floor just claimed and reopen the
no-op loop. Sliding forward instead moves the cut past the end of the group,
so the whole call/result pair is summarised together and nothing is
orphaned. The same sweep reports 0 violations after the change, and the
progress guarantee is pinned by its own test.
2026-07-20 11:58:50 +05:30
kshitijk4poor
3c72177061 fix(config): widen doctor allowlist to all gateway-bridged top-level keys
Salvage of PR #67447 — the original PR fixed 3 of 7 missing keys.
gateway/config.py reads 4 more top-level keys (stt_echo_transcripts,
reset_triggers, always_log_local, filter_silence_narration) that
produced the same false 'Unknown top-level config key' warning.
Add all 4 and extend the regression test to cover them.
2026-07-20 11:53:10 +05:30
HexLab98
54157da9ee test(config): cover doctor allowlist for Hermes-written root keys
Regression for known_plugin_toolsets / group_sessions_per_user /
thread_sessions_per_user so validate_config_structure no longer
false-positives on keys Hermes owns.
2026-07-20 11:53:10 +05:30
HexLab98
7c2ece53c0 fix(config): whitelist Hermes-owned roots doctor falsely flagged
Hermes writes known_plugin_toolsets via tools_config and bridges
group_sessions_per_user / thread_sessions_per_user in gateway/config,
but doctor treated them as unknown top-level keys. Add them to
_EXTRA_KNOWN_ROOT_KEYS so validation matches keys Hermes itself uses.
2026-07-20 11:53:10 +05:30
Drexuxux
371ee065fd fix(gateway): don't spend a redelivery attempt when the platform is down
The delivery ledger durably records a final response before the send so a
crash between finalize and platform ACK can redeliver it on the next boot.
attempts is that redelivery budget, capped at MAX_ATTEMPTS=3.

sweep_recoverable() claims every dead-owner row and increments attempts
before the caller knows whether it can send. self.adapters only holds a
platform after its connect() succeeded, so when the platform failed to
connect this boot _redeliver_pending_obligations() hits its "adapter is
None" branch and continues WITHOUT sending — but the attempt is already
spent. Three such boots and the row abandons, having never been sent once.

That is the loss the ledger exists to prevent, and the trigger correlates
with the crash that created the obligation: the network trouble that killed
the send tends to still be there on the next boot. Worse, the message stays
lost — once abandoned it is never retried even after the platform recovers.

Reproduced against the real runner with an unconnected adapter:

    boot 1: claimed=1 state='attempting' attempts=1  (0 sends attempted)
    boot 2: claimed=1 state='attempting' attempts=2  (0 sends attempted)
    boot 3: claimed=1 state='attempting' attempts=3  (0 sends attempted)
    boot 4: claimed=0 state='abandoned'  attempts=3  (0 sends attempted)

Let the caller declare which platforms it can send on, and skip claiming
rows for the others. attempts then only ever buys a real send. Rows for a
platform that never returns are still bounded by the stale cutoff, so
nothing accumulates. The parameter is keyword-only and optional — omitting
it keeps the previous claim-everything behaviour for other callers.
2026-07-20 11:52:41 +05:30
Ben Barclay
f0aae14c68
fix(desktop): retry OAuth cookie read on cold-start jar race (#67769)
A `persist:` partition's cookie store hydrates lazily, so the first
cookies.get() on a fresh launch can return empty for a signed-in user.
That false-negative made hasLiveOauthSession() throw "not signed in",
which on the no-retry initial boot path surfaced as the transient
"Hermes couldn't start" OAuth overlay that always cleared on Retry.

hasLiveOauthSession now reads once (no added latency on the happy path);
only on an empty read does it warm the store (flushStorageData + a
throwaway get, memoized) and re-read with a bounded ~180ms backoff
before trusting the negative. Genuinely signed-out users still resolve
false quickly and get the overlay. Fixes the whole class: the same
function backs the reconnect path and the Settings connected indicator.
2026-07-20 16:01:38 +10:00