Commit Graph

22727 Commits

Author SHA1 Message Date
kshitijk4poor
71fe5fccad refactor(model_switch): remove parse_model_flags, which only tests called
Every /model surface parses through parse_model_switch_args. The legacy
5-tuple wrapper had no production caller; its remaining references were two
tests of the tuple shape, an unused import, and two tui_gateway patch()
calls on a function tui_gateway no longer calls.

The en-dash --session normalization those tests covered moves into the
parse_model_switch_args keeper. test_model_switch_once_flags and
test_model_switch_context_offload are removed: the first re-tests the
private parser the keeper already drives, the second the one-line to_thread
wrapper that tests/gateway/test_model_command_context_offload.py proves off
the event loop through the real /model handler.
2026-09-29 15:58:55 +05:30
kshitijk4poor
9ee791eef1 test: drop tests that cannot fail or duplicate a same-file keeper
Each removed test either asserts nothing reachable or repeats another
test's exact body:

- auxiliary_client empty-base_url: body only writes config.yaml; a merge
  (890bbbda1f) restored the header of a test b3d4f67b20 had purged. The
  non-Anthropic-host test beside it drives the same default fallback.
- background_review no-session-db: _record_review_usage_to_parent swallows
  every exception and there is no DB to observe, so removing the guard
  still passes; the path has no observable effect to pin.
- code_execution Windows "would have failed" pair: every branch returns,
  skips or passes; they exercise CPython defaults, not Hermes code.
- cron test_should_use_direct_api_call_*: byte-identical copy of the test in
  tests/agent/test_cron_inline_api_call.py (0213b8b77b had to edit both).
- cron liveness no-multiplexer: identical body to test_no_lock_no_pids_is_false;
  its multiplexer patch is never reached.
- browser console/get_images "private URLs allowed": identical to the
  local-backend test in each file; both mock the predicate the name promises.
- hermes_state TestTitleUniqueness: the duplicate-title case is a strict
  subset of TestSessionTitleLineage::test_unrelated_session_still_conflicts;
  the NULL-title case never writes a title.
2026-09-29 15:58:55 +05:30
kshitijk4poor
fbb0eb35e1 fix(cli): missing sticky profile error names the recovery command
When active_profile names a deleted profile, the error still said
"Create it with: hermes profile create <name>", a command that exits with
the same error. Name the command that works instead:
`hermes profile use default`. An explicit -p keeps the create hint.
2026-09-29 15:45:52 +05:30
kshitijk4poor
167d3bb6f3 fix(cli): stale-profile recovery refuses uninstall --full and abbreviated --data
The recovery allowlist checked for the literal string "--data", but the
uninstall subparser accepts argparse abbreviations: `hermes uninstall --dat`
passed the guard and removed the default root's data. `--full` was allowed
outright, and in the default root it rmtree's every other profile under
profiles/ as well, a wider wipe than the --data mode the guard refuses.

Parse the uninstall argv with the real subparser (output silenced; the
handler reports errors itself) and allow it only when neither data nor full
resolves true. `uninstall --help` exits 0 during that probe and is let
through so the handler prints help once instead of help plus an error.
The predicate lives in a topical sibling (main_profile_recovery.py) rather
than growing the main.py facade.
2026-09-29 15:45:52 +05:30
Gille
a4748e9224 fix(cli): allow recovery from missing active profile 2026-09-29 15:45:52 +05:30
unsupportedpastels
536802c00e fix(debug): mask every secret fallback field per agent.redact policy, not only api_key
Reuses the repr-field secret-name policy (is_secret_field_name) so token, password, secret and credential-suffixed fields are masked by field in hermes dump, debug share and /debug. The regression test asserts on what actually reaches the paste service first.
2026-09-29 05:59:25 -04:00
unsupportedpastels
f907520593 fix(debug): mask fallback api_key by field, whatever its YAML shape
Text redaction of str(fallback_providers) missed api_key values that the
runtime still uses (resolve_entry_api_key str()s them): an unquoted numeric
key, a mapping, or a key containing "***" (treated as already masked).
Mask the field before serialising; keep the independent upload-boundary
pass and cover it with #126168's boundary test.

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-29 05:59:25 -04:00
John Paul Soliva
cf94e3eea3 fix(redact): GCS and Azure SAS signatures count as URL credentials
The strict URL-credential pass that hermes dump and debug uploads use
redacted X-Amz-Signature but not X-Goog-Signature (GCS V4 signed URLs)
or sig (Azure SAS), so a fallback base_url signed that way still reached
the dump and the upload. The documented promise is signed-URL signatures
in general.
2026-09-29 05:59:25 -04:00
John Paul Soliva
139876e727 test(debug): URL credentials in a fallback base_url never reach hermes dump or an upload
Covers userinfo, ?key=, X-Amz-Signature= and #access_token= through the real config.yaml, hermes dump stdout, the share bundle and the /debug report. Docs (EN + zh-Hans) say the dump redacts credentials in fallback URLs.
2026-09-29 05:59:25 -04:00
John Paul Soliva
e70fbbe3ec fix(debug): redact the system dump in debug share and gateway /debug
hermes dump prints config_overrides.fallback_providers as the raw list,
api_key values included, and the upload-time redaction of hermes debug
share and the gateway /debug report covered only the log snapshots, not
the dump text quoted at the top of every paste.

Force-redact the captured dump like the logs (--no-redact still opts
out), and have hermes dump itself redact fallback_providers so a pasted
dump never carries a key.
2026-09-29 05:59:25 -04:00
kshitijk4poor
4e5cea136a test(update): a same-named script outside the checkout still repairs it
Pins the path half of the tail identity: only this checkout's own
hermes_cli/ scripts are exempt from the launch-time repair.
2026-09-29 15:25:13 +05:30
kshitijk4poor
6c7ebd1c01 fix(update): hermes update's own tail no longer starts a second tail
update_completion.py arms the pending marker, then imports hermes_cli.main,
which runs prepare_launch. While the updater's lock claim is live the
ancestor check returns early, but an unwritable claim or one past the
20-minute ceiling sends it into _finish_source_update: a full
source_completion tail runs inside the update and clears the marker before
the update's own build has finished.

Exempt both tail scripts through one identity check. The launch check
already resolves its root, so the comparison is exact on the process's
own argv[0], which the processes a tail spawns do not inherit.
2026-09-29 15:25:13 +05:30
Gille
de48024d75 fix(update): prevent recursive source completion
(cherry picked from commit 481e68381ebb9bbc36dd7b99876b6fd8b833584a)
2026-09-29 15:25:13 +05:30
kshitijk4poor
596678d665 refactor(update): tighten the partial-clone heal after review
- _deepen_shallow_repo marks packs in its finally without a git config probe
  that could itself raise and turn a finished unshallow into False; markers
  are inert in a clone without a promisor remote.
- Installers warn instead of aborting the stage when a marker cannot be
  written, matching the Python helper.
- Docs: the manual commands follow HERMES_HOME, and the missing-object
  backfill feeds IDs through `fetch --stdin` so a large list cannot exceed
  the Windows command-line limit.
- Test and docstring comments describe the contract, not the incident.
2026-09-29 15:24:15 +05:30
kshitijk4poor
7b20b72269 fix(install): installer rerun heals a partial clone stuck on the pack-objects crash
An install whose updater dies on the git 2.53+ pack-objects BUG (#124272)
never fetches the release that heals it: the fetch runs in the installed
code. The installer rerun (install.sh / install.ps1, served fresh) is the
path that runs new code, so when origin is a promisor remote it now marks
the checkout's unmarked packs before its existing-checkout fetch. Marking is
idempotent and never rewrites objects, so it runs up front rather than as a
retry that would first print a failed-fetch report.
2026-09-29 15:24:15 +05:30
kshitijk4poor
282ad01cb5 fix(update): mark unmarked packs instead of the inert promisor retry (#124272)
git 2.53+ `index-pack --promisor` hands every object outside the promisor
packs to pack-objects, which BUG()s (should_include_obj) on the missing
objects those objects lead to. A partial clone with unmarked packs therefore
keeps crashing its fetches.

The retry added for this ran `git -c remote.origin.promisor= fetch`. That
override does nothing: git registers promisor remotes additively, so the
repo's own `true` still wins (GIT_TRACE on 2.50 and 2.55 shows
`index-pack --promisor` and `filter tree:0` under the override). The retry
only passed when the crashed fetch had already stored the pack; a retry that
has to transfer again crashed again.

- On the crash, give every pack without a `.promisor` marker one, then retry
  the same fetch once. The clone stays partial, no refetch, no config change.
  If git still crashes, the updater points at the docs section.
- The two fetches that turn a full clone partial now mark the clone's old
  packs afterwards: the depth-1 `tree:0` unshallow in
  `fetch_full_commit_graph`, and `worktree_ops._deepen_shallow_repo`
  (`--filter=blob:none`). git writes the partial-clone config before it
  fetches, so this also runs when the fetch fails. Without it, a converted
  depth-1 install crashed every later fetch on git 2.55 (3 of 3 rounds in a
  local repro).
2026-09-29 15:24:15 +05:30
Q
70b9cdafd4 fix(update): recognize the git 2.55 partial-clone pack-objects crash
is_partial_clone_pack_objects_crash() required "index-pack failed" next to the
pack-objects assertion, but git 2.55 builds dropped that string: Homebrew git
2.55.0 (macOS) only reports "fatal: fetch-pack: invalid index-pack output", so
the recognizer always returned False there, fetch_with_partial_clone_recovery()
never retried with the promisor machinery disabled, and `hermes update` exited 1
on the crash (#125138 reports the same bare-assertion failure on 2.55.0.windows.5).

Fingerprint on the assertion text — the only version-stable part of the failure
— and accept the 2.55 wrapper as a terminator. The POSIX/Windows shapes and the
"fingerprint without a terminator" rejection behave as before.

(cherry picked from commit 74103b15ceb3eacb2346fca669c3a3dc81457201)
2026-09-29 15:24:15 +05:30
kshitijk4poor
af26acab73 fix(deps): gate the runtime takeover on genuinely historical updater frames
The salvaged matcher accepted any cmd_update/_cmd_update_impl frame in
hermes_cli.update_cmd/main, so the CURRENT updater's entrypoints (which
carry pre_update_version on opts, not as a local) would also hand off and
exit the process mid-`hermes update`. Extract
_old_updater.in_historical_update() sharing _historical_context's exact
frame matcher (module, name, and pre_update_version in co_varnames) so the
shim and the context detector cannot drift apart.

- tools/lazy_deps: use the shared predicate; accept the constraints/
  dry_run kwargs historical updaters pass so they reach the handoff
  instead of dying on TypeError; drop the vague message advice.
- tests: collapse 29 parametrize cases to the real invariants and add the
  missing one — the frame walk is now exercised through a nested helper
  (a getframe(1)-only implementation fails), and a current-updater-shaped
  frame (no pre_update_version local) is asserted NOT to hand off.
2026-09-29 15:08:07 +05:30
funky-xamarin
5e6165562b fix(deps): refuse runtime lazy installer update takeovers 2026-09-29 15:08:07 +05:30
kshitijk4poor
7728574ad4 test(cli): fold foreign-venv wrapper case into the migration test
The standalone ownership test duplicated the parametrized boot migration
(same wrapper, weaker check), was the only test in tests/hermes_cli calling
the private _publish_conveniences, and asserted a mtime-derived "changed"
flag immediately after writing the file, which is flaky on filesystems with
coarse timestamps.

Keep its one unique invariant, another checkout's venv/bin/hermes is never
adopted, by pointing the migration test's existing foreign hermes-agent
wrapper at another checkout's venv. It still also mentions this install in a
comment, so both rejection cases stay covered through public expose_cli().
2026-09-29 14:36:55 +05:30
Gille
91986da496 fix(cli): adopt legacy venv hermes wrapper
(cherry picked from commit ccdbc32d93358c1d0c8219ab3e9441a8a49fdbee)
2026-09-29 14:36:55 +05:30
Gille
ea114c3e98 fix(install): preserve apostrophe paths in Windows runtime launch 2026-09-29 13:19:27 +05:30
Siddharth Balyan
3a439b69ad Free tier: separate 'account exists' from 'runs this chat', and stop the mint from writing active_provider (#127486) 2026-09-29 07:44:13 +00:00
chelsealong
ef2013231d fix(gateway): scope the browse-cap exemption to registry commands only
The previous fix exempted every kind != "skill" item from the browse
cap, on the premise that non-skill items only come from the fixed,
~150-entry COMMAND_REGISTRY. That's not true: plugin-registered slash
commands (PluginContext.register_command) are also classified as
kind "command" but are an unbounded, user-installed source — the same
shape the cap exists to bound for skills. A large plugin install would
have flooded a bare `/` completely uncapped.

Only exempt names actually in GATEWAY_KNOWN_COMMANDS (derived from
COMMAND_REGISTRY) from the browse cap; other kind:"command" items,
including plugin commands, keep the existing 30-item cap.
2026-09-28 21:39:39 -05:00
chelsealong
a0ef9f7477 fix(gateway): stop truncating the command block on a bare / browse
_rank_slash_completions applied the same 30-item cap to registry
commands and user-installed skills. The cap exists to bound the
skill block (which can grow to hundreds of installs); the command
registry is fixed and curated (~150 entries). On a bare `/`, that
shared cap put /goal, /subgoal, /loop, /queue, /personality and
/debug past position 30 and hid them from the desktop and TUI
browse list entirely, even though they still resolved by prefix.

Only skip the cap for commands when browsing (text == "/"); a
typed search still bounds both blocks.

Fixes #101388
2026-09-28 21:39:39 -05:00
Brooklyn Nicholson
e85706cba7 fix(tui_gateway): a queued model switch crosses the compute-host boundary
On a compute-host (turn_isolation) session the live agent lives in the child
process, but config.set model either stashed pending_model_switch for a turn
thread that pops-and-drops it against agent=None (busy), or built a second
agent in the serving process and switched that copy while the child kept the
old model (idle). Carry the stash in the turn frame (copied, not popped), let
the child's _ensure_server_session adopt it in both branches, and clear the
server-side stash only after a successful isolated turn.

Rebases tsemana's #79510 onto the post-split modules: the frame key and the
clear-on-success now live in compute_host_bridge.py, adoption in
compute_host.py, and the always-defer condition in methods_config_set.py.

Fixes #79509
Co-authored-by: Tony Semana <tsemana@users.noreply.github.com>
2026-09-28 21:12:23 -05:00
Herman Bohdanov
9521d6de55 fix(tui_gateway): hold the /goal continuation after Stop too
_run_post_turn_followups claimed the session for a goal continuation
checking only running, so a Stop that landed after the model answered
but before the follow-ups ran still chained the next goal turn. Refuse
it while the Stop latch is set; the goal resumes after the next prompt.

Bot live delivery stays ungated on purpose: an envelope is a person's
message rather than an automatic turn, and only a local prompt clears
the latch. A comment at the gate says so.
2026-09-28 21:11:22 -05:00
Herman Bohdanov
966737be6b fix(tui_gateway): start no automatic turn after the user's Stop
Stop sets _turn_cancel_requested, but _notif_claim_turn checked only
running, so a background-process or delegation completion arriving during
or after the stopped turn immediately started a new model turn. The claim
now also refuses while the latch is set. That gates completion, delegation
and kanban turns and /loop and /heartbeat ticks; callers already requeue
(or keep the tick due), and the next prompt.submit clears the latch.
2026-09-28 21:11:22 -05:00
Yingliang Zhang
b8b38e55dd fix(state): handoff rows keep the durable parent timestamp for lineage dedupe
Rotation re-inserts the compressor's handoff tail as fresh child rows. Carried
tail dicts arrive without a timestamp, so _insert_message_rows stamped them
with publish-time now(); the parent's durable originals kept their own
timestamps. The two copies then differ in _display_dedupe_key
(role, content, timestamp, tool fields) and _dedupe_display_generations shows
both -> duplicated rows in the ancestor-lineage display read (#59661), once
per segment boundary in a long chain.

publish_compression_child now adopts the durable parent row's timestamp onto
carried rows before insert (best-effort, never overwrites a timestamp the dict
already carries). archive_and_compact gets the same carry for in-place double
compaction, and _insert_message_rows back-stamps the persisted timestamp so
later rotations inherit it. The existing dedupe key and every read path are
unchanged; the collapse happens because identities now agree.
2026-09-28 20:17:37 -05:00
Yingliang Zhang
cfd5dc3876 fix(api): scope ancestor reads to display surfaces; guard after_id; stamp read-path fixture
Quad-review follow-up (4 legs ACCEPT-WITH-NITS, P0s independently reproduced):

- _conversation_history_for_session reverts to tip-only: it feeds
  conversation_history for session chat / chat stream / v1 runs (model-fed
  restore must not regrow what compaction summarized away); the
  include_ancestors=True expansion stays on the DISPLAY reads
  (_handle_session_messages + web router). Restores the delegation delivery
  contract test (probe pinned the model history to the tip segment).
- get_messages now raises ValueError for after_id + include_ancestors
  instead of silently ignoring the cursor on a merged-lineage read.
- test_session_db_read_path_split fixture stamps the parent
  end_reason='compression' the way a real rotation (publish_compression_child)
  does; the old unverified parent link is a shape no production writer
  creates and the verified walker correctly rejects.

Verification: test_session_db_read_path_split 9 passed;
test_api_delegation_delivery_contract 2 passed; test_hermes_state.py 270
passed / 1 pre-existing FTS5 base failure (fails identically on 6e07eb4838);
tests/hermes_state/ 310 passed 3 skipped; gateway session-api +
compaction-projection + read-path 44 passed; resume-display + conversation-root
+ tui resume 32 passed; ruff + py_compile + git diff --check clean.
2026-09-28 20:17:37 -05:00
Yingliang Zhang
e5867d9bae fix(state): verified compression lineage for ancestor reads + fork marker
- _resume_lineage_ids walks verified compression links only (each parent ended
  'compression', child not an explicit fork), so API forks, reset children, and
  subagents stay single-session — fixing the [assistant, user, assistant]
  duplicated-answer history ehz0ah reproduced on API forks (#59661 review).
- _handle_fork_session writes _branched_from like the CLI/TUI branch paths.
- _handle_session_messages passes include_ancestors=True (REST session API
  half of the original fix, lost in a later squash).
2026-09-28 20:17:37 -05:00
Yingliang Zhang
cf63a03e39 fix(rest): include compression-ancestor messages in /api/sessions/{id}/messages
Port of #59661 onto post-#102117 main. The refactor moved get_messages
into hermes_state_messages.py (SessionMessagesMixin) and made the REST
endpoint resolve the resume tip first, so the port adds the
include_ancestors kwarg to the new home — reusing _resume_lineage_ids
(same branch-session exemption as get_messages_as_conversation) and the
IN-clause pattern already used by _fetch_conversation_rows — and passes
include_ancestors=True at the REST messages endpoint and the gateway
API server's history loader.

After a compression rotation the transcript spans parent sessions; the
REST endpoint previously returned only the child continuation, hiding
the pre-compaction transcript while session.resume showed it (#51058).

Tests: 3 new tests in tests/test_hermes_state.py (lineage expansion,
explicit-branch exemption, merged-set paging); 34/34 lineage/branch/resume
suite green.
2026-09-28 20:17:37 -05:00
unsupportedpastels
99b24916ce fix(photon): don't read a signal-raced sidecar exit as a fatal crash
Same race as the WhatsApp bridge (#127047): a container/supervisor stop
signals the whole process tree, so the Photon sidecar (its own session)
can die before the gateway's stop flow reaches disconnect(), while
_inbound_running is still True. _supervise_sidecar() then reported
SIDECAR_CRASHED and queued a reconnect during shutdown.

Consult the runner's _stop_requested_by_signal, which the signal handler
sets before any stop work runs. A sidecar exit while the gateway keeps
running is unchanged: still a retryable fatal error.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-09-28 21:06:08 -04:00
liuhao1024
f5a3cd6bd8 fix(whatsapp): don't read a signal-raced bridge exit as a fatal crash
A supervisor's stop signal (docker stop, s6-supervise, systemd) reaches the
bridge child in the same process group before the gateway's stop flow reaches
WhatsAppAdapter.disconnect(), so _shutting_down is still False when the poll
loop observes the -15 exit. _check_managed_bridge_exit() then reports a
retryable fatal adapter error, the stranded-platform check exits the gateway
with failure, and the supervisor restarts it into a crash loop (#127047).

Also consult the runner's _stop_requested_by_signal, which the signal handler
flips before any stop work runs. A bridge SIGTERM/SIGINT while the gateway
keeps running is unchanged: still a retryable fatal error so the reconnect
watcher revives it.

(cherry picked from commit 252ae311f06e402f9a8ef1482c87aaefe8d9ac39)
2026-09-28 21:06:08 -04:00
Austin Pickett
e33f1a0faf fix(desktop): refuse window-owned pane requests at once when no window shows the session
preview.read / preview.act / terminal.read / window.read / tour are answered
only by the Desktop window that shows the requesting session (#114981). When
no attached window showed it, every window stayed silent and the agent waited
out the full 30-45s bridge deadline, then got the generic "No preview tab is
open, or the read timed out."

A window not hosting the session now declines with JSON-RPC error 4404
instead of staying silent. The gateway counts a decline as that client's vote,
not as the answer: the request stays open for the owner window and settles
only once every attached answering client declined, with a distinct refusal
("No Hermes Desktop window is showing this chat ... Ask the user to open this
chat in the Desktop app, then retry."). A bystander window therefore still
cannot beat the owner (#113348).

The backend advertises the counting via client.capabilities
(declines_not_shown); the shared channel only sends a decline to a backend
that did, so a new app against an older backend keeps today's silence.

Fixes #119333
2026-09-28 21:04:47 -04:00
Austin Pickett
0f50687327 fix(update): report a symlinked other-install profile gateway as external, not unaccounted
A profiles/<name> symlinked to a separate Hermes install is inventoried by the
update plan, left alone by the restart phase, and already classified
'external' by the post-restart fleet matrix. Reconciliation had no such
outcome, so the gateway ended 'unaccounted' and every hermes update exited 1.

Feed the fleet's verified external gateway pids into match_runtime_outcomes:
an untouched gateway in that set is 'external', reported informationally and
excluded from the exit-1 tripwire. No fleet evidence keeps it unaccounted.

Fixes #120240
2026-09-28 21:04:13 -04:00
Austin Pickett
6397a84a91 fix(sessions): un-hide an auto-archived chat when it is resumed or compressed
The idle sweep archives a whole compression lineage. When the chat later
resumed and compressed, the new tip was inserted with archived=0 under the
archived root, and the sidebar admits a lineage by its root's flag, so the
live chat stayed hidden.

Record sweep provenance in a new sessions.auto_archived column. Publishing a
compression child or reopening a session under a sweep-only archive
un-hides the lineage; a deliberate archive (sidebar, API, CLI) clears the
provenance and keeps the chat hidden. Compression children now inherit the
parent's archive state so a manually archived lineage stays uniform.

Fixes #117713
2026-09-28 21:03:09 -04:00
Austin Pickett
d619b67ec2 fix(local-runtime): spill only the recurrent FFN blocks a hybrid needs
A spilled hybrid got `-ot blk\.\d+\.ffn_.*\.weight=CPU`, which moves every
block's FFN — full-attention blocks included — regardless of how many bytes
actually had to leave the GPU. The GGUF reader now records FFN weight bytes
per block, and spill_overrides picks the fewest recurrent (n_head_kv==0)
blocks whose FFNs cover the decision's spill_bytes, emitting them as an
explicit `blk\.(i|j|...)\.ffn_.*\.weight=CPU` selector. Unknown sizes fall
back to every recurrent block, never a full-attention one.

Based on #120778 by @kvnloo (recurrent-index selector).

Covers the placement half of #113329; the overhead/logits calibration
(RUNTIME_OVERHEAD_BYTES, ub_logits_bytes) is untouched pending measured data.
Refs #113329
2026-09-28 21:02:39 -04:00
Austin Pickett
f6df13bac6 fix(update): keep never-pushed and unmerged branches pinned in source check
An empty ls-remote advertisement cannot tell "merged and deleted upstream"
from "never pushed". The source check now re-pins a missing branch to main
only when the local branch was once published (remote-tracking ref or a
configured upstream) and git cherry finds no commits missing from main.
Otherwise it keeps the branch and reports branch-local-only.

Diagnosis from #105053 by @0xalydev and #105055 by @kokhlo, which targeted
the Electron healer since moved into hermes_cli/source_check.py.

Fixes #105042
2026-09-28 21:02:35 -04:00
Austin Pickett
90b0b21537 fix(update): record Desktop's correlation_id in the update receipt
Desktop's managed SSH update exports HERMES_UPDATE_CORRELATION_ID and
accepts only a receipt whose correlation_id equals it. The backend never
read the variable, so every successful remote update was reported as
failed and restored. Persist it as correlation_id when the receipt is
created, and keep it across the interpreter handoff.

Idea from @LordMelkor on the issue.

Fixes #101516
2026-09-28 21:02:29 -04:00
Austin Pickett
81f481b2db fix(agent): carry a custom provider's extra_body into TUI/Desktop agents and aux calls
TUI/Desktop `_make_agent` (plus `hermes -z` and ACP) built AIAgent without the
resolver's `request_overrides`, so a custom entry's `extra_body` only reached
the agent through the init-time re-derivation, which matches `custom:*` alone.
Auxiliary requests never read the entry's `extra_body` at all. A proxy that
requires a body field (`user`) therefore 400'd in the app and on smart
approval while `hermes chat` worked.

- Pass `runtime["request_overrides"]` at every remaining AIAgent build site
  (ACP only when no explicit base_url points at another endpoint).
- `_build_call_kwargs` layers the destination entry's `extra_body` under the
  task/caller body, using the agent's own matcher, so primary, retry and
  fallback requests each get their own endpoint's body and nothing leaks.

Covers holes 1 and 3 of #103738; hole 2 (matching bare/unprefixed names) is
#124290 and is untouched here.
2026-09-28 19:49:00 -04:00
Austin Pickett
bf84561b9d fix(tui_gateway): keep a container backend's terminal.cwd as the session workspace
Under a container terminal backend (docker/modal/...), terminal.cwd names a path
inside the sandbox (/workspace). _completion_cwd host-validated it with
os.path.isdir, failed, and fell back to the gateway's own cwd ($HOME for the
desktop). Every host file then looked "inside the workspace", so file.attach
never staged it into the bind-mounted attachments/ dir and the agent was handed
a host path that does not exist in the container.

- _completion_cwd keeps an absolute container cwd unvalidated when the bound
  backend is non-local (matching _terminal_task_cwd), including a named
  profile's own container terminal.cwd and a config-only (unbridged) backend.
- _session_is_local_backend reads the effective backend (env or config), so a
  container cwd is never "healed" to its nearest host ancestor (/).
- map_cache_path_to_container also matches through a symlinked HERMES_HOME:
  @file: expansion passes resolved paths, the mount roots keep the configured
  spelling, so staged attachments leaked their host path.

Covers the desktop attachment path of #103147. Typed @file: refs to binary
files outside the mounted dirs still render the host path.
2026-09-28 19:41:57 -04:00
Austin Pickett
7154128fe1 fix(tui-gateway): answer gateway.ping while a WS handler blocks dispatch
The WS read loop awaited each inline dispatch() before reading the next
frame, so a handler stuck behind a long compaction (or any lock/GIL/DB
stall) left gateway.ping unread. The desktop's 45s heartbeat deadline
then tore down a busy but healthy backend and killed the turn.

Dispatch now runs on a per-connection task that keeps the serial arrival
order; the reader answers pings directly. Teardown still waits for the
in-flight handler and frames read before the disconnect.

Fixes #108325
2026-09-28 18:58:29 -04:00
kshitijk4poor
1fd412e8a0 test(sessions): drop the SCHEMA_VERSION import the trigram test no longer reads 2026-09-29 04:15:21 +05:30
Eva
19d54c0611 fix(gateway): replay keeps message identity on plain user and assistant rows
_build_gateway_agent_history rebuilds a session's own transcript for its
next turn. Rows that pass through whole (tool-calling assistants, tool
results) kept message_uid and the merge witness, but _build_replay_entry
rebuilt plain user and assistant rows from an allowlist that left both out.
A context engine therefore saw the store's uids on some roles and none on
others in the same replayed conversation.

The replay entry now copies message_uid and the merge witness on every
role. It leaves out the tool-call uid maps: a plain row whose unanswered
tool_calls repair pruned can still hold a map, and replaying it would name
calls the row no longer carries.
2026-09-29 04:15:21 +05:30
kshitijk4poor
0cabdf3f64 fix(sessions): an assistant fold ignores a non-dict uid map on the surviving turn too
The absorbed turn's map already had the dict guard; a plugin-built survivor carrying a malformed
map still raised in the per-occurrence expansion. Pre-existing (the old merge raised the same way).
2026-09-29 04:15:21 +05:30
kshitijk4poor
dd66c132f2 fix(sessions): keep tool-call uids paired through rewrites, folds of repeated ids and chunked copies
Found by the pre-merge correctness and hermes-specific review of this stack:
- A fold after a row whose one response repeated a provider id (one shared uid) appended the
  absorbed turn's uid in the wrong slot. The shared uid now fills each of the row's own
  occurrences before the fold appends (per_occurrence_tool_call_uids). A single response keeps one
  shared uid for a repeated id: every result carries it, so no call looks unanswered.
- A digest-less re-flush of a restored fold (replay heal, adopt after a digest mismatch) let the
  stored pre-fold map overwrite the longer live list, so the later occurrence's results paired
  with nothing. A live list that starts with every stored occurrence is kept (stored uid or list).
- A dict that lost its uid map (a clone) rewrote tool_call_uids to NULL. The row-addressed
  rewrite now fills stored uids for the calls the dict still names; a call it dropped keeps none.
- append_messages_batch(chunk_rows=...) reset the pairing index per chunk, so a result in the chunk
  after its call was stored without tool_call_uid (branch/seed copies). The whole batch is paired
  before it is chunked.
- _merge_assistant_into keeps its dict guard on the absorbed turn's map (a plugin-built dict with a
  non-dict map no longer raises).
2026-09-29 04:15:21 +05:30
kshitijk4poor
2be9fa5f9d refactor(sessions): simplify-code pass over the identity helpers
- without_persistence_fields() replaces the two hand-written strips (context-engine selection,
  token-estimate fingerprint).
- mint_uid() is the one uid minter; uid_list() the one uid-list normalizer (message_metadata's
  _absorbed_uids and the codec's _uid_list share it).
- _live_or_column() replaces four copies of 'live key first, column name second'; the absorbed
  list writer takes the dict, not a (live_key, column) pair its one caller always passed the same.
- _restore_row_identity() is the one stored-identity-onto-live-dict step, shared by restore and
  the row-addressed rewrite (transcript_repair had a hand copy). Restore skips the decoders for
  NULL columns (the common row).
- index_tool_call_uids() returns the variants it named, so the flush walk stops computing them
  twice; tool_call_uid_from_history drops guards its one caller can't trip.
- rewind returns the replacement dict (the insert already stamps _row_id and message_uid)
  instead of re-querying last_insert_rowid.
- _remember_absorbed_row's folded is required, so every call site states the witness rule.
- The backfill reads/deletes state_meta through _meta_row/_delete_meta; chat_completions strips
  MESSAGE_UID by constant.
No behaviour change.
2026-09-29 04:15:21 +05:30
kshitijk4poor
5d8b17f865 fix(sessions): an assistant fold that discards the later text records no merge witness
_merge_assistant_into never joins multimodal (list) content, so a non-empty later turn
beside a list (or a list beside non-empty text) is dropped, yet the fold still recorded its
uid in _absorbed_message_uids, a false claim that its text lives on. The merge now reports
whether the later text was kept and the witness follows it; the retired row id is still
recorded.

Reported by @andrexibiza on #126307.
2026-09-29 04:15:21 +05:30
kshitijk4poor
d9808867c7 fix(sessions): an assistant fold keeps every occurrence uid of a reused provider id
Folding two assistant turns that reuse a provider id (llama.cpp-style constant ids) leaves
both calls in tool_calls, but the {id: uid} union kept only the later uid, so the first
occurrence lost its identity. The id now maps to a list of uids, one per occurrence in
tool_calls order (merge_tool_call_uids); index_tool_call_uids pairs each call with its own
occurrence, so a following result still pairs with the nearest call. Provider ids are never
rewritten. Persisted and restored as-is (the JSON map accepts list values).

Reported by @andrexibiza on #126307.
2026-09-29 04:15:21 +05:30