Commit graph

16543 commits

Author SHA1 Message Date
ethernet
18ca0e862c
Merge pull request #66471 from NousResearch/ethie/typescript-lsp
fix(lsp): never report stale diagnostics — version-gated freshness for slow servers
2026-07-20 14:16:50 -04:00
Teknium
0e281b58e6 fix(matrix): class-level split-threshold defaults for partially-constructed adapters
Text-batching tests (and any tooling) build MatrixAdapter via
object.__new__ without running __init__; moving _split_threshold from a
class constant to an instance attribute made _flush_text_batch die with
AttributeError, silently dropping the flush. Restore class-level
defaults (max_message_length, _split_threshold) that __init__ overrides,
and derive the near-limit test payload from adapter._split_threshold
instead of the old hardcoded 3950.
2026-07-20 11:10:49 -07:00
Teknium
086a56a028 fix(matrix): correct platform hint over-claims, add regression tests + docs
Follow-up to the salvaged #52552 and #53083 commits:

- Rework the Matrix PLATFORM_HINTS entry around what the adapter actually
  emits: headings, numbered lists, blockquotes, strikethrough-free markdown
  all render (the adapter converts them to sanctioned HTML). Keep the
  genuinely valuable guidance: no Markdown tables (Element X / Beeper /
  mobile clients don't render HTML tables — cells collapse into one line),
  no spoilers/checkboxes/~~strikethrough~~ (not converted by
  python-markdown), prefer descriptive link text.
- Regression test: hint must steer models away from tables.
- Fix test_long_response_split_preserves_thread_context to derive its
  payload size from the adapter's configurable limit instead of assuming
  the old hardcoded 4000.
- Document matrix.max_message_length in the Matrix docs page.
- Contributor mapping for RKelln.
2026-07-20 11:10:49 -07:00
nankingjing
35e0f56fbf fix(matrix): make outbound message length configurable (#53026)
Raise the Matrix adapter default chunk size from 4,000 to 16,000
characters and allow overrides via config.yaml or MATRIX_MAX_MESSAGE_LENGTH.

Fixes #53026
2026-07-20 11:10:49 -07:00
Ryan Kelln
08626c18be fix(prompt_builder): improve Matrix PLATFORM_HINTS with tested formatting rules
- Replace outdated brief hint with comprehensive, tested rules
- Document exactly what renders on Matrix and what does not
- Add critical linebreak semantics (two trailing spaces = soft break)
- Add link formatting guidance (descriptive text, never bare URLs)
2026-07-20 11:10:49 -07:00
Teknium
a5b9803a51 test(transports): assert Moonshot wire schema carries required:[]
Transport-level regression for #66835 — verifies the outgoing tool
schema at the chat_completions build_kwargs boundary, not just the
sanitizer unit.
2026-07-20 11:05:52 -07:00
PRATHAMESH75
acc1b6e76a fix(agent): inject empty required array on Moonshot object schemas
Moonshot/Kimi's tool-parameter validator rejects object schemas that omit
the required key with HTTP 400 ("required must be an array"), even though
standard JSON Schema allows omitting it. Any Hermes tool with zero required
parameters (browser_back, delegate_task, project_list, several MCP list_*
tools, etc.) tripped this when routed to a Moonshot endpoint.

Add a Rule 4 to the sanitizer: every object schema gets a required array,
defaulting to []. Existing lists are preserved but pruned to names that
actually appear in properties (dangling entries are also rejected upstream).
Applied recursively to nested object schemas and to the coerced/empty
top-level fallback.

Fixes #66835
2026-07-20 11:05:52 -07:00
ethernet
eb09df6ec8 refactor(lsp): version-tagged _DocState replaces timestamp freshness tracking
The staleness fix (f9b1fd799) bolted two wall-clock dicts (_changed_at,
_pulled_at) onto a client that already scattered per-document state
across six parallel dicts (_files, _push_diagnostics, _pull_diagnostics,
_published, _published_version, _first_push_seen) — eight maps kept in
sync by hand.

Collapse all of it into one _DocState per path, and use the LSP document
version as the freshness token instead of clocks:

- didChange bumps doc.version; stored push/pull results carry the
  version they describe (push_version from the server's echoed version,
  or the current version at receipt for servers that don't echo one;
  pull_version captured at request send so an in-flight pull that a
  didChange races past is stale on arrival).
- fresh == tag >= version. Invalidation is implicit in the bump — no
  store-clearing, no clock comparisons, no race windows.
- _has_fresh_push/_has_fresh_pull helpers dissolve into two one-line
  _DocState methods; diagnostics_for(fresh_only=True) becomes a
  three-liner.

Semantics are unchanged from f9b1fd799 (same tests pass, one test
updated off private internals); net -15 lines.
2026-07-20 13:43:36 -04:00
ethernet
a632e68a01 fix(lsp): never report stale diagnostics — wait for fresh post-edit data
Slow language servers (tsserver on large projects especially) publish
diagnostics long after an edit. The client's wait/report path had three
holes that together surfaced the PREVIOUS edit's errors as if they were
current ("ghost diagnostics"), sending the agent chasing errors it had
already fixed:

1. open_file only cleared the diagnostic stores on first open — on the
   didChange path (every subsequent edit) stale push/pull entries
   survived.
2. wait_for_diagnostics' predicates were satisfiable by that leftover
   state (`path in _published`, `path in _pull_diagnostics`), so the
   "wait" often returned instantly with old data.
3. diagnostics_for merged the stale push store unconditionally, so even
   a fresh clean pull got the old error merged back in.

Fix: anchor freshness on a per-file didChange timestamp.

- Pull results record their request send-time and are dropped when a
  didChange raced past them; the pull store is invalidated on every
  change, not just first open.
- wait_for_diagnostics now returns bool (fresh data vs timeout), only
  counts pushes published at/after the change (and version >= ours when
  the server echoes versions), and accepts an explicit timeout — the
  user's lsp.wait_timeout config now actually controls the inner wait
  budget instead of only the outer thread-join.
- diagnostics_for(fresh_only=True) excludes stores that predate the
  latest change; all manager report paths use it.
- On timeout the manager returns [] ("no data") instead of stale
  state, logs a WARNING via eventlog, and does NOT mark the server
  broken — slow is not dead.
- seed-on-first-push no longer marks the file published, so the TS
  seed push can't satisfy a waiter.

Tests: new "stale" and "slow_push" mock-server scripts model the slow
tsserver, plus client- and service-level regression tests
(tests/agent/lsp/test_stale_diagnostics.py).
2026-07-20 13:43:36 -04:00
Teknium
456f18b19c fix(picker): scope exact-ID resolution to lossy alias collapses only
The cherry-picked resolve_provider_full 0.5 step returned a generic
openai_chat ProviderDef for ANY registry ID, hijacking single-entry
alias rewrites like copilot -> github-copilot away from their overlay
transports (test_explicit_copilot_switch_uses_selected_model_api_mode
regression). Restrict the early return to names where MULTIPLE registry
providers collapse to one canonical (kimi-coding + kimi-coding-cn +
kimi + moonshot -> kimi-for-coding) — the only case where alias
resolution actually loses information.

Also maps Almurat123's contributor email.
2026-07-20 10:17:57 -07:00
Almurat
52e16c1138 fix: preserve kimi-coding-cn provider identity 2026-07-20 10:17:57 -07:00
Almurat
2ffdf08376 fix: show both kimi-coding and kimi-coding-cn in /model picker
Both providers share the same models.dev ID (kimi-for-coding) but
have different API keys (KIMI_API_KEY vs KIMI_CN_API_KEY) and base
URLs (moonshot.ai vs moonshot.cn).  The /model picker was only
showing one because the dedup key was mdev_id alone.

Changes in list_authenticated_providers():
- Resolve canonical provider profile name and skip alias hermes_ids
  (e.g. "kimi", "moonshot" → "kimi-coding") so only canonical
  entries are processed.
- Deduplicate by slug (hermes_id) instead of mdev_id so distinct
  profiles sharing a models.dev ID (kimi-coding vs kimi-coding-cn)
  both appear.
- Prefer PROVIDER_REGISTRY name for the display label so the CN
  variant shows "Kimi / Moonshot (China)" instead of the generic
  models.dev name.

Adds test coverage for all three key scenarios:
- Only KIMI_CN_API_KEY set → only kimi-coding-cn appears
- Only KIMI_API_KEY set → only kimi-coding appears
- Both keys set → both providers appear, aliases not duplicated

Closes #10526
2026-07-20 10:17:57 -07:00
AIalliAI
b99e1e3bf6 fix(model): collapse kimi alias/canonical to one /model picker row
A single Kimi credential surfaced two rows in the `/model` picker — the
bare alias `kimi` (PROVIDER_TO_MODELS_DEV pass) and the canonical
`kimi-coding` (CANONICAL_PROVIDERS cross-check, section 2b) — both backed
by the same `kimi-for-coding` provider.

`kimi`, `moonshot` and the canonical `kimi-coding` all map to one
models.dev id (`kimi-for-coding`). The seen_mdev_ids guard collapses them
to the first key in section 1, but that key is the bare alias, so 2b
re-emits the canonical name as a second row.

Emit the row under the canonical Hermes slug instead: resolve the alias
via _PROVIDER_ALIASES (`kimi` -> `kimi-coding`) before appending, so 2b's
seen_slugs check collapses the pair. This matches the picker's other alias
rows (copilot, gemini) and the overlay slug-resolution contract, and keeps
the surviving row resolvable to the real provider. A defensive seen_slugs
guard prevents emitting a duplicate canonical row.

Distinct providers keep their own row: `kimi-coding-cn` has its own
KIMI_CN_API_KEY and is still emitted by section 2b.

Regression tests assert the single-key case yields one `kimi-coding` row
(fails on clean main, which shows both `kimi` and `kimi-coding`) and that
the China endpoint is preserved.

Fixes #49439
2026-07-20 10:17:57 -07:00
ethernet
3133af8215
fix(desktop): prevent duplicate messages when verification candidates are persisted (#68149)
The display_history_prefix calculation used by session.resume's
_live_session_payload was display_history[:len(display) - len(raw)].
This assumed the model (repaired) history is always a suffix of the
display history — i.e., repair_message_sequence only removes messages
from the tail. That assumption broke when verification candidates
(finish_reason=verification_required) were persisted to state.db (#65919):

  - repair collapses consecutive assistant messages, removing the
    verification candidate from the MODEL history
  - the candidate stays in the DISPLAY history (it's real persisted content)
  - the length gap (gap = len(display) - len(raw)) counts BOTH ancestor
    messages AND repair-removed tip messages
  - the prefix = display[:gap] grabs the first N display messages, which
    are tip messages (not ancestors) when there are no compression ancestors
  - _live_session_payload concatenates prefix + model_history, duplicating
    the first N messages

On session 20260720_110036_a33889 (8 verification candidates), this
duplicated the first 8 messages in every warm-cache session.activate
response, producing visible duplicate user messages in the desktop.

Fix: add SessionDB.get_ancestor_display_prefix() which returns ONLY
genuine ancestor messages (rows where session_id != tip_session_id),
identified at the row level before _rows_to_conversation strips
session_id. Both resume paths (eager + deferred) now use this instead
of the length-slice heuristic.

Tests:
  - test_get_ancestor_display_prefix_single_session_returns_empty
  - test_get_ancestor_display_prefix_returns_ancestor_only_messages
  - Updated 12 mock DBs across test_protocol.py + test_tui_gateway_server.py
  - 848 passed (run_tests.sh), 0 regressions
2026-07-20 17:14:41 +00:00
nousbot-eng
aa274364bb
fmt(js): npm run fix on merge (#68135)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-20 16:46:35 +00:00
ethernet
470c7e2a60
fix(desktop): prevent timers from shifting as they count (#68131)
LiveDuration returned a bare string with proportional digits, so the
statusbar reflowed every second as the timer ticked. Extract a shared
StableText component that renders each character in its own 1ch-wide
cell, preventing any digit from shifting the layout — works with the
proportional sans font, no need for font-mono.

Both LiveDuration (statusbar session/running timers) and
ActivityTimerText (tool activity timers) now use StableText, dropping
the font-mono + tabular-nums workaround from the latter.

Also renames statusbar.ts → statusbar.tsx since LiveDuration now
returns JSX.
2026-07-20 16:39:20 +00:00
xxxigm
9403b4f8ba fix(feishu): keep msg_type=post consistent across every chunk of a long markdown reply (#26841)
Transplant of PR #26848 onto the plugin adapter path
(plugins/platforms/feishu/adapter.py — the original PR targeted the
since-removed gateway/platforms/feishu.py).

``send`` classifies each chunk independently, so chunk 1 of a long
markdown reply (often plain prose) went out as msg_type=text while
later chunks rendered as post — literal **bold**/## heading markers
in the Feishu client. Lock the decision at the whole-message level:
compute prefer_post once from the full formatted message and pass it
to _build_outbound_payload per chunk.

The original PR's per-chunk table exemption is intentionally dropped:
tables now route through post/md (issue #52786 cluster fix), so the
exemption would reintroduce the raw-table downgrade.

Co-authored-by: Hermes Agent <hermes@nousresearch.com>
2026-07-20 09:28:24 -07:00
Teknium
17a99f6b15 chore: map contributor ly-wang19 for #49551 salvage 2026-07-20 09:21:08 -07:00
Teknium
c0dff40e3a test: use shutil.copy2 instead of os.link for cross-device tmp fixtures
TestPtyWebSocket's two python-resolution tests and the sibling fixture in
test_tui_resume_flow.py hard-linked sys.executable into pytest's tmp_path.
On machines where /tmp is a different filesystem than the venv (tmpfs vs
disk home) os.link raises OSError EXDEV and the tests fail before reaching
any assertion. copy2 preserves the executable bit and works across devices.
2026-07-20 09:21:08 -07:00
Teknium
faa4cec01b fix(credentials): hoist read-guard import, fail closed loudly (#67665)
Follow-up to #67640: move the agent.file_safety import to module top
(stdlib-only, no circular-import concern), replace the over-broad
except Exception + logger.warning with an import sentinel plus
logger.exception so a guard failure is debuggable instead of silently
swallowed. Adds fail-closed tests asserting the diagnostic is emitted.
2026-07-20 09:21:08 -07:00
ly-wang19
28028cce55 fix(web): clear stale api-alias credential on provider switch in main-model assignment
c253b0738 added clear_model_endpoint_credentials() to scrub an old endpoint's
inline secret (api_key, the legacy `api` alias, api_mode) when the web UI
switches the main model to a different provider. But _apply_main_model_assignment
gates the key-scrub path on model_cfg["api_key"] being truthy, so when the stale
secret lives only under the legacy `api` alias (no api_key), a provider switch
never clears it — the secret survives in config.yaml.

model.api is a live credential read path (_resolve_openrouter_runtime reads
`for k in ("api_key", "api")`), so the old endpoint's key contaminates a later
custom resolution — the exact harm clear_model_endpoint_credentials documents.
The sibling persistence sites (the gateway model-picker paths and the aux-slot
path) call the helper unconditionally on a non-custom switch and already scrub
`api`; only this caller had the api_key-only gate.

Widen the guard to fire on either field. The same-provider re-pick and
explicit-new-key paths are unchanged. Adds the api-alias case to the assignment
test (it fails without the fix).
2026-07-20 09:21:08 -07:00
Teknium
977884e6cd chore: add contributor email mappings for PR #58019 / #29552 salvage 2026-07-20 09:19:24 -07:00
M1racleShih
ae22a03ef6 fix(feishu): render markdown tables via post md
Route table-shaped Markdown through the existing post/md builder so current Feishu clients render tables instead of showing source markup.

Add a direct payload regression test that checks the post message type and decoded md element.
2026-07-20 09:19:24 -07:00
JasonFang1993
a660630986 fix(feishu): render markdown tables via post+md, not text downgrade
Resolves issue #52786 (duplicate of #23938):

The `_build_outbound_payload` shortcut forced any message containing a
pipe table to ``msg_type=text``.  Feishu readers then rendered the raw
pipe-and-dash source instead of a table.  Empirically current Feishu
clients render markdown tables inside ``post``-type ``md`` elements
natively, so the downgrade branch had to go.

Two changes:

1. ``_MARKDOWN_HINT_RE`` now also matches a pipe-table header+separator
   pair, so a table-only message is recognised as "has markdown" and
   takes the ``post`` path.  All previously recognised hints (headings,
   lists, code, bold/italic/strike/underline, links, blockquotes, hr)
   still match — verified by the existing 205 test_feishu.py cases plus
   the new regression tests below.

2. ``_build_outbound_payload`` no longer special-cases `_MARKDOWN_TABLE_RE`
   before the hint check.  The hint check now routes table content to
   `_build_markdown_post_payload`, which is the same path any other
   markdown structure takes.

``_MARKDOWN_TABLE_RE`` itself is retained as a module-level constant for
external callers (import-path-sensitive tests, third-party consumers of
the adapter module) and continues to work for its existing uses.

Tests
-----
New: ``tests/gateway/test_feishu_table_markdown.py`` — four regression
tests:

- ``test_markdown_table_uses_post_not_text`` — pure-table content
  reaches ``post`` (issue #52786 scenario).
- ``test_table_combined_with_other_markdown_does_not_downgrade`` —
  prose + table + prose message keeps its surrounding markdown.
- ``test_existing_markdown_heading_still_uses_post`` — sanity guard:
  the heading path is unchanged.
- ``test_plain_text_without_markdown_still_uses_text`` — negative
  control: pure prose still goes to ``text``.

Verification
------------
``pytest tests/gateway/test_feishu.py
tests/gateway/test_feishu_table_markdown.py`` passes 209/209 (205
existing + 4 new), three consecutive runs.

Rollback
--------
``git reset --hard 44ddc552f5``
restores upstream main without the new test file.
2026-07-20 09:19:24 -07:00
Teknium
5e999b98cb test: fix stale k3 fixture in deepseek signed-thinking replay test
test_deepseek_still_strips_signed_thinking passed model='k3' with the
DeepSeek base URL — that only held because bare 'k3' wasn't classified
as Kimi family yet. With k3 now correctly classified, the kimi-family
model-name path (deliberate: proxied endpoints preserve thinking,
#13848/#17057) keeps the blocks. Use a real DeepSeek slug for the
DeepSeek behavior, and add an explicit invariant test that Kimi-family
slugs (named and bare) keep thinking on foreign gateway hostnames.
2026-07-20 08:47:55 -07:00
Teknium
25eafd7d71 fix(models): complete kimi-k3 rollout across Kimi-direct catalog surfaces
Follow-up widening for salvaged PRs #67115, #67685, #67620:

- _PROVIDER_MODELS: add kimi-k3 atop kimi-coding / moonshot / opencode-go
  curated lists (kimi-coding-cn covered by cherry-picked #67620)
- setup.py _DEFAULT_PROVIDER_MODELS: kimi-k3 for kimi-coding(-cn) + opencode-go
- model_metadata: align DEFAULT_CONTEXT_LENGTHS kimi-k3 entry to 1,048,576
  (matches endpoint-scoped override, models.dev, and OpenRouter live metadata)
- anthropic_adapter: classify the bare Coding Plan slug 'k3' (and k3.x/k3-*)
  as Kimi family so adaptive thinking applies on proxied endpoints
- moonshot_schema: is_moonshot_model matches bare 'k3' so tool-schema
  sanitization runs on the chat-completions path
- contributor mappings for githubespresso407, datachainsystems, Punyko8

Tests: 582 passed across 11 targeted files; hermetic E2E verifies picker
order (kimi-k3 first), no dupes, and 1M context resolution.
2026-07-20 08:47:55 -07:00
Punyko8
495d4acec5 feat: add kimi-k3 + kimi-k2.7-code to kimi-coding-cn whitelist
Moonshot China (api.moonshot.cn) has rolled out kimi-k3 to all
CN-endpoint keys, plus the new kimi-k2.7-code / -highspeed variants.
The kimi-coding-cn curated picker whitelist was still on the k2.6/k2.5
era list, so users holding CN keys could not select any of the new
models from 'hermes model' or the gateway /model picker even though
the underlying provider + endpoint already serve them.

Verified against a live CN key:

    GET https://api.moonshot.cn/v1/models
    -> [kimi-k3, kimi-k2.7-code, kimi-k2.7-code-highspeed, kimi-k2.6,
        kimi-k2.5, moonshot-v1-* ...]

No provider-code changes; pure whitelist addition mirroring the
existing kimi-coding (global) list which already tracks k2.7-code.
2026-07-20 08:47:55 -07:00
datachainsystems
54c39c0301 fix: add Kimi K3 1M context window to DEFAULT_CONTEXT_LENGTHS
Kimi K3 ships with a 1M-token context window (verified against
platform.kimi.ai/docs/overview) but was falling through to the generic
'kimi': 262144 catch-all. Added 'kimi-k3': 1_000_000 before the catch-all
so longest-key-first substring matching resolves K3 to 1M while older
Kimi models still hit the 256K default.

Added matching test_kimi_k3_context_1m test covering native,
vendor-prefixed (kimi/, moonshotai/), and older model fallback.
2026-07-20 08:47:55 -07:00
githubespresso407
77aa026ca6 Resolve kimi-k3 context length to 1M on canonical Kimi Coding endpoints
Kimi Coding serves K3 under the bare slug 'k3', but users can also
configure or select the public-facing aliases 'kimi-k3' and
'kimi-k3-cot'. The endpoint-scoped 1M context window was only keyed
on the bare 'k3' slug, so selecting 'kimi-k3' fell through to the
generic 'kimi' catch-all (262k).

Extend the guard in _endpoint_scoped_context_length to also recognize
'kimi-k3' and 'kimi-k3-cot', while keeping the endpoint check that
limits the 1M value to https://api.kimi.com/coding (legacy Moonshot
endpoints still fall back to 262k). Update the existing test to cover
all three aliases.

Fixes: context window limited to 262k when using kimi-k3 via kimi-coding.
2026-07-20 08:47:55 -07:00
ethernet
c363db81e0
fix(desktop, ink): don't wipe messages before final message (#65919)
* fix(desktop): preserve interim assistant text wiped at message.complete

When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.

This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).

The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.

Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.

Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.

Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.

The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.

Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.

Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.

Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.

_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)

- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>

* fix: prefix-match interim streamed content to avoid benign duplicate bubbles

_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.

Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().

* test(desktop): add partial-stream-then-nudge dedup edge case

Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.

Acceptance protocol #2 — covers all three dedup edges:
  1. interim == final (existing)
  2. interim = strict prefix of final (existing)
  3. partial-stream-then-nudge (this commit)

---------

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
2026-07-20 11:42:29 -04:00
Frowtek
b520f507cc fix(dashboard): clear the model mirror when its custom endpoint is deleted
activate_custom_endpoint copies the endpoint's base_url and api_key onto
cfg["model"]. delete_custom_endpoint pops the providers entry and saves —
it never touches that mirror.

So deleting the endpoint the agent is currently using leaves both behind:

    DELETE /api/providers/custom-endpoints/acme  -> 200
    providers entry gone : True
    model.api_key        : sk-CUSTOM-ENDPOINT-SECRET
    model.base_url       : https://llm.acme.corp/v1

Two consequences, both silent:

  * The agent keeps authenticating to the deleted host with the deleted key.
    model.api_key outranks the environment at client construction, so this
    also shadows whatever the operator configures next — the persistent-401
    shape credential_lifecycle.py documents as #62269.
  * A credential the operator just removed through the dashboard stays
    sitting in config.yaml.

Scrub the main-slot mirror on delete, but only when it actually names the
deleted provider — an endpoint deleted while a different one is active must
leave that active assignment untouched. Both directions are pinned by tests.
2026-07-20 08:37:14 -07:00
Frowtek
6bedec4734 fix(dashboard): don't wipe hand-written provider fields on custom-endpoint edit
_write_custom_endpoint builds a fresh entry dict from the request body and
assigns it over providers[endpoint_id], carrying nothing forward but api_key.

A providers.<name> block is not owned by that panel. It can carry keys the
dashboard has no field for, all of them load-bearing:

  api_mode          the protocol the endpoint speaks
  key_env           where the credential comes from
  extra_headers     per-provider HTTP headers (may carry credentials)
  request_overrides extra body params

and a models map with more than the one model the panel names.

So an edit that only changes the default model destroys the rest:

    BEFORE  api_mode, base_url, extra_headers, key_env, model, models,
            name, request_overrides
    AFTER   base_url, discover_models, model, models, name

    FIELDS DESTROYED: ['api_mode', 'extra_headers', 'key_env',
                       'request_overrides']

The provider is left with no credential wiring (key_env gone, no api_key),
talking the wrong protocol, missing its proxy auth header — from a UI action
that said nothing about any of that. The models map also collapses to the one
named model, dropping the others and their context_length.

Merge onto the existing entry instead of replacing it, and merge the models
map rather than overwriting it. Managed fields still win, so the edit itself
still applies; a brand-new endpoint is unchanged. api_key keeps its previous
semantics — a supplied key overwrites, an omitted one leaves the stored key
in place (now via the merge rather than an explicit carry-forward branch).
2026-07-20 08:37:14 -07:00
ethernet
1705a44074
fix(nix): include apps/shared as tui dep (#68109)
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions
https://github.com/NousResearch/hermes-agent/pull/61067
added a @hermes/shared dep for `ui-tui`. added it to the nix deps to fix
builds
2026-07-20 11:23:34 -04:00
UnathiCodex
8f33e39682
fix(desktop): prevent false runtime-not-ready under gateway load (#66174)
* fix(desktop): stabilize runtime readiness polling

* fix(tui_gateway): pool live-session status polling

* fix(desktop): clear readiness when gateway disconnects
2026-07-20 10:51:27 -04:00
Po-Han Shih
3e6cead363
fix(desktop): stop session.info from overwriting composer model (#66603)
Closes #66265. Credit: Stan Shih (stantheman0128).

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Austin Pickett <pickett.austin@gmail.com>
2026-07-20 10:40:59 -04:00
UnathiCodex
c59ca46940
fix(desktop): keep quiet sessions visibly running (#65870)
* fix(desktop): keep quiet sessions visibly running

* fix(desktop): restore running status after reconnect

* fix(desktop): keep live session status unmistakable
2026-07-20 10:31:58 -04:00
Victor Kyriazakos
59fdd41f5a fix(gateway): filter finalized first response before queued follow-up send
A successful turn returning exactly NO_REPLY (or another exact silence
marker) leaked the literal control token when a second message was queued
before the first turn finished. The queued-follow-up recovery branch sends
the first final_response directly through adapter.send() and predates the
silence filter added to the normal completed-turn path.

Use the finalized task result for that recovery delivery rather than the raw
result_holder copy, then apply the existing
is_intentional_silence_agent_result() predicate before sending. This keeps
the established contract: only successful exact-marker turns suppress;
substantive prose and failed results still send; stream-confirmed responses
still skip the resend; and persisted history is untouched. Using finalized
output also preserves normal empty/failure normalization on this direct path.

Integration regressions run the real Slack _run_agent queued-follow-up flow:
one proves NO_REPLY never reaches the adapter while the second turn still
runs; another proves an empty failed first turn sends its normalized error
before the queued follow-up. Existing filter tests cover every supported
marker, prose mentions, and failed-result semantics.
2026-07-20 07:24:07 -07:00
nousbot-eng
3d7e1c5f43
fmt(js): npm run fix on merge (#68065)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-20 13:58:53 +00:00
Austin Pickett
2bb531b67a
fix(desktop): extend read aloud + transcription timeouts (salvage of #39286) (#68056)
* fix(desktop): extend read aloud timeout

Hermes Desktop Read Aloud can show a false failure after 15s even when
backend TTS synthesis succeeds. The Desktop renderer bridge request to
/api/audio/speak blocks until provider synthesis, audio file read, and
base64 response encoding finish, so larger messages or slower remote TTS
providers can legitimately exceed the default 15s Electron backend
timeout (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts).

Give only the blocking Read Aloud request a bounded TTS-specific timeout
(180s floor, 600s cap, text-length scaling). Normal Desktop API requests
keep the short default so real backend hangs still fail promptly.

Salvage of #39286 rebased onto current main (original branch conflicted
in apps/desktop/src/hermes.ts and hermes.test.ts against the newly-added
STARTUP_REQUEST_TIMEOUT_MS / PROMPT_SUBMIT_REQUEST_TIMEOUT_MS constants
and model-options tests). Both additions coexist; no behavior changed.

Co-authored-by: Ruslan Vasylev <ruslan.vasylev.vfx@gmail.com>

* fix(desktop): extend read aloud + transcription timeouts

Hermes Desktop Read Aloud can show a false failure after 15s even when
backend TTS synthesis succeeds. The Desktop renderer bridge request to
/api/audio/speak blocks until provider synthesis, audio file read, and
base64 response encoding finish, so larger messages or slower remote TTS
providers can legitimately exceed the default 15s Electron backend
timeout (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts).

/api/audio/transcribe is the sibling blocking endpoint with the same bug
class: it blocks on provider STT + file handling + encoding behind the
same 15s default, so long clips / remote providers hit the same spurious
timeout. Give both requests a bounded, endpoint-specific timeout (180s
floor, 600s cap) — speak scales on text length, transcribe on the base64
payload length. Every other Desktop API request keeps the short default
so real backend hangs still fail promptly.

Salvage of #39286 rebased onto current main. The original PR was scoped
to /api/audio/speak only; this extends it to also close the transcribe
twin per OutThisLife's review suggestion on #39286, closing the whole
'audio endpoints time out at 15s' class in one shot.

Co-authored-by: Ruslan Vasylev <ruslan.vasylev.vfx@gmail.com>

---------

Co-authored-by: Ruslan Vasylev <ruslan.vasylev.vfx@gmail.com>
2026-07-20 09:51:31 -04:00
Frowtek
183712ab82 fix(delegation): redact credentials in live subagent transcripts
The live transcripts added with delegate_task write each child's events to
<hermes_home>/cache/delegation/live/<delegation_id>/. Nothing on that path is
redacted, and the rendered events are precisely the secret-bearing surfaces:
tool args, tool results and streamed assistant text.

That location is not incidental — delegate_tool.py:1620 documents cache/
delegation as "mounted read-only into remote backends", so every line lands
in a file readable from inside the sandbox.

Observed on the writer today:

    tool   | -> terminal(curl -H "Authorization: Bearer sk-ant-api03-...")
    result | terminal ok 0.4s: OPENAI_API_KEY=sk-proj-... AWS_SECRET_ACCESS_KEY=wJalr...

The same three values through the canonical redactor:

    curl -H "Authorization: Bearer ***" https://api.internal
    OPENAI_API_KEY=*** AWS_SECRET_ACCESS_KEY=***

Every other sink for this data already routes through that redactor — search
results via redact_sensitive_text(file_read=True), terminal output via
redact_terminal_output — so the transcript was the one place an operator's
keys reached disk in the clear.

Redact at three points, covering every artefact the dispatch writes into that
directory:

  * event() — every typed helper (assistant_text, thinking, tool_start,
    tool_result, marker, finalize, the stream flush) funnels through it, so
    one call covers them all and a helper added later cannot bypass it.
  * the .log header — written directly rather than through event(), and a
    caller can paste a key into the task text.
  * manifest.json — _write_manifest serialises the same goal, and the manifest
    sits in the same mounted directory, so redacting only the header would
    have left the credential exposed one file over.

force=True because this is a safety boundary and must redact even with the
global toggle off. On the (impossible-in-practice) import failure the line is
withheld instead of written raw: losing a debug line costs less than writing
a live credential into a sandbox-readable file.

Key names, tool names, statuses, durations and ordinary prose are untouched,
so tail -f stays as useful as before — pinned by its own test, alongside a
whole-directory sweep asserting no file under live/<id>/ carries the raw key.
2026-07-20 06:50:31 -07:00
Frowtek
c8882c141c fix(credentials): never mount master credential stores into skill sandboxes
register_credential_file() takes a skill-declared relative path from
required_credential_files frontmatter and bind-mounts it read-only into the
remote sandbox the skill's own code runs in. It validates that the resolved
path stays inside HERMES_HOME — the docstring names the threat directly:

    so that a malicious skill cannot declare
    required_credential_files: ['../../.ssh/id_rsa'] and exfiltrate
    sensitive host files into a container sandbox

Containment is the wrong boundary on its own, because HERMES_HOME is exactly
where the master credential stores live. Traversal is blocked; asking for the
keys by name is not:

    skill declares               mounted?   agent may read it?
    .env                         YES        DENIED
    auth.json                    YES        DENIED
    .anthropic_oauth.json        YES        DENIED
    cache/bws_cache.json         YES        DENIED
    mcp-tokens/srv.json          YES        DENIED
    google_token.json            YES        allowed
    ../../.ssh/id_rsa            no         n/a

Every row marked DENIED is refused by the canonical read guard
(agent.file_safety.get_read_block_error) — the agent cannot read_file them —
yet one line of hub-installed skill frontmatter gets them bind-mounted where
that skill can cat them. .env alone is every provider API key.

Reuse the canonical deny-list as the mount bar: what the agent is forbidden
to read is not mountable either, so the mount surface cannot hand a skill
what the read surface denies it. Fails CLOSED — if the guard can't be
consulted the mount is refused rather than risked.

The module keeps doing its job: a skill still mounts its own service token
(google_token.json, skills/*), and a refused entry is reported back through
register_credential_files' missing list instead of failing the batch.

The three prior PRs here (#3946, #3951, #4316) all hardened traversal; this
closes the half that traversal validation never covered.
2026-07-20 06:50:26 -07:00
PRATHAMESH75
e77ffdc28c fix(tools): bound env probe subprocess so a Windows inherited pipe can't wedge sessions (#67964) 2026-07-20 06:50:11 -07:00
Frowtek
38a274b297 fix(dashboard): let an explicit api_key win over the provider entry's stored one
POST /api/model/set accepts an api_key and threads it into
_apply_main_model_assignment. The custom-endpoint work then added a
provider-entry fallback right after it — but unconditionally:

    if not base_url and provider_entry.get("base_url"):
        base_url = provider_entry["base_url"]          # explicit wins
    model_cfg = _apply_main_model_assignment(..., base_url, api_key)
    if provider_entry.get("api_key"):
        model_cfg["api_key"] = provider_entry["api_key"]   # explicit LOSES

The two lines disagree about precedence. base_url fills only a gap; api_key
overwrites whatever the caller sent.

So rotating a key through this endpoint returns 200 and silently keeps the
old one:

    request api_key : sk-NEW-ROTATED-KEY
    stored  api_key : sk-STORED-OLD-KEY

That matters beyond the write itself: model.api_key outranks the environment
at client construction, so the stale key keeps authenticating and shadows
anything the operator configures next — the persistent-401 shape
credential_lifecycle.py documents as #62269.

A regression, not long-standing. Against 3d9789357^ the same request stores
sk-NEW-ROTATED-KEY.

Gate the fallback on `not api_key`, matching the base_url line directly above
it. Switching to a configured provider with no key in the request still adopts
the entry's key — pinned by its own test so the feature's intent doesn't
regress in the other direction.
2026-07-20 06:48:59 -07:00
Teknium
31c08a9aad chore: map contributor email for context probe 2026-07-20 05:49:44 -07:00
Teknium
58391436f7 fix: reconcile probe cache with stale-entry invalidation + stale test fixtures
- The #44861 stale-cache guard invalidated any cached value that differed
  from the static table, which would have discarded legitimate
  probe-derived windows larger than the table. Treat the table as a
  FLOOR: only drop under-reporting cache entries.
- Update probe test fixtures that predated the 4.6+ 1M table flip
  (opus-4-6 fallback expectations 200K -> 1M).
2026-07-20 05:49:44 -07:00
kubolko
6be4944bc0 fix(bedrock): probe real context window instead of stale static table
Bedrock models resolved their context window from a hardcoded table
(BEDROCK_CONTEXT_LENGTHS) keyed by longest-substring match. AWS ships
new model versions faster than the table tracks, so a new model like
claude-opus-4-8 (1M-token window) silently matched the older
"anthropic.claude-opus-4" entry and got capped at 200K — wasting 80%
of the available context.

Bedrock exposes the real window nowhere in metadata: get-foundation-model
omits it, Converse usage metrics omit it, CountTokens is unsupported on
several models. The only authoritative source is the ValidationException
raised when a prompt exceeds the window:

    "prompt is too long: 1300032 tokens > 1000000 maximum"

Length validation runs before inference, so an oversized request is
rejected immediately and cheaply (no tokens generated, no input
processed). This adds probe_bedrock_context_length(): pad a request just
past a tier, parse the reported maximum, return it. get_bedrock_context_length()
now probes first and falls back to the static table only when the probe
can't run (missing creds, network error, unparseable error). The static
table stays as a safety net.

get_model_context_length() caches the probe result per model+region, so
the network cost is paid once, not every turn. probe=False / empty region
disables probing for offline/display paths — backward compatible with the
single-arg callers.

Verified E2E against live Bedrock (eu-central-1): claude-opus-4-8 resolves
to 1000000. Unit tests cover error parsing, unparseable errors, missing
client, probe-beats-table, and table fallback.
2026-07-20 05:49:44 -07:00
Frowtek
222772ad61 fix(gateway): bridge nested DingTalk allowed_users into auth env
The DingTalk docs offer gateway.platforms.dingtalk.extra.allowed_users
as the config.yaml alternative to DINGTALK_ALLOWED_USERS. The adapter
honors it (_load_allowed_users reads PlatformConfig.extra), but gateway
authorization (_is_user_authorized in gateway/authz_mixin.py) only
consults the env var, and load_gateway_config() bridged the allowlist
to the env var only from a top-level dingtalk: block. A nested-only
allowlist therefore passed the adapter and was then denied at the
gateway - listed users fell through to pairing/default-deny in DMs.

Extend the DingTalk YAML->env bridge to fall back to the merged nested
platform config (gateway.platforms / platforms), mirroring the existing
platforms.discord.extra.allow_from precedent. Precedence is unchanged:
an explicit DINGTALK_ALLOWED_USERS env var still wins, then the
top-level dingtalk: block, then the nested extra.

Also correct the docs' claim that the two allowlists are "merged" when
both are set - that behavior never existed (the doc line came from a
docs-only sweep); the effective result is the intersection of the two
gates, so the docs now recommend configuring one or the other.

Repro (before): config.yaml containing only the nested allowlist ->
adapter._is_user_allowed("user-id-1") is True but
runner._is_user_authorized(...) is False. After: both True; unlisted
users are still denied.
2026-07-20 05:39:24 -07:00
Teknium
330b224525 chore: map logical-and contributor for #62873 salvage 2026-07-20 05:39:09 -07:00
And
4cbceae9f4 fix(gateway): normalize YAML boolean streaming mode and keep enabled a mode-only alias
Address PR #62873 review:

- Bare YAML `mode: off`/`on` parse to Python False/True (YAML 1.1). Stringifying
  False yielded "false" (not "off"), so `mode: off` wrongly enabled streaming.
  Add _normalize_transport_token() to map booleans to canonical off/auto tokens,
  mirroring the normalization documented in gateway/display_config.py.
- Only the `mode` alias infers `enabled`; a bare `transport` no longer enables
  streaming, preserving `streaming.enabled` as the documented master switch
  (website/docs/user-guide/configuration.md).
- Update tests to the corrected contract and add YAML-boolean coverage plus
  loader-level regressions for unquoted `mode: off` and nested mode enable.
2026-07-20 05:39:09 -07:00
And
62cdb3e1be Enable streaming when only streaming.mode is set
- StreamingConfig.from_dict now treats `mode` as an alias for `transport`
  that also implies `enabled`, so `streaming: {mode: auto}` turns streaming
  on instead of being silently ignored (enabled defaulted to False, which
  buffered the whole reply and sent it in one message)
- `mode: off` disables streaming; an explicit `enabled` key still wins; an
  explicit `transport` takes precedence over `mode`
- Add regression tests covering mode/transport/enabled precedence and the
  real-world `mode + preloader_frames` block
2026-07-20 05:39:09 -07:00