6098 Commits

Author SHA1 Message Date
kshitijk4poor
fc53717f72 fix(files): one live-DB refusal source; hold the lock through /api/files/read
Gate r2 Low cleanups (house rule: no aliases/shims):
- Drop the is_live_database_file alias; its point-in-time caveat now lives on
  has_live_connection.
- _refuse_live_database reuses offline_file_access's message (via _serve_offline),
  so a download 409 on state.db-shm names the main database like the read path;
  the verb is "serve" so it fits read/download/stream.
- /api/files/read reads whole files in-process, so _read_base64_file now holds
  offline_file_access through close (409 on a live DB; OSError stays 500). Only
  the streamed FileResponse routes keep the point-in-time check.
- That check takes the global _live_lock, which other threads hold across
  whole-file reads, so fs_download and the managed stream routes run it via
  asyncio.to_thread instead of stalling the event loop.
- _managed_readable_file docstring no longer claims a size cap;
  _read_file_reference returns (early, text) instead of a str|Expansion union
  sniffed with isinstance.

Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
2026-09-27 20:45:56 +05:30
kshitijk4poor
48196b0ceb fix(files): refuse live-DB downloads and share one sidecar-aware liveness check
FileResponse opens and closes the file in the dashboard process, so
downloading a live state.db (or its -shm/-wal) via /api/fs/download or
the managed-file read/download/media routes still cancelled the
connection's POSIX locks. Both now return 409 via is_live_database_file;
the registry lock is not held across the streamed response.

The main-or-WAL-sidecar rule now lives in one _live_main_key helper used
by offline_file_access, has_live_connection and read_header_bytes_preopen,
and the sidecar refusal names the main database the connection is open on.

@file previews hold _live_lock only for the raw read; token counting and
formatting run after release. The Linux lock test gains requires_wal
(Hermes uses DELETE mode on WAL-reset-vulnerable SQLite), covers the
download refusal, and drops an ambiguous conditional assert.

Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
2026-09-27 20:45:56 +05:30
Benjamin PERRY
af595ff8a6 fix(files): preserve SQLite locks during previews
Co-Authored-By: Hermes Agent / OpenAI Codex / gpt-6-sol <noreply@agents.invalid>
(cherry picked from commit 60d879c125177cd4cacdbc840f16c2e048b4199c)
2026-09-27 20:45:56 +05:30
kshitijk4poor
f337631f43 fix(persistence): restore caller row state when any transcript insert rolls back
_insert_message_rows stamps _row_id and the stored-row digest onto the
caller's dicts inside the write transaction. Only append_messages_batch
restored that state on rollback. archive_and_compact, replace_messages
and the rotation handoff left the rolled-back id + digest on the dicts;
SQLite reuses the id, so a later flush found a digest mismatch on the
foreign row, adopted it and silently dropped the user's message.

Move the capture/restore into _execute_transcript_write, used by every
caller that inserts caller-owned dicts: each attempt starts from the
caller's state and a final failure restores it before re-raising.
(Rewind replacement and import insert dicts built inside the txn.)

Also: bind _message_row_params directly on insert instead of the
serialized-dict round-trip, import the public DB_ROW_SNAPSHOT /
CANONICAL_ROW names, set adopt=False once, and reuse target_row
instead of re-SELECTing when nothing was written.
2026-09-27 20:45:44 +05:30
kshitijk4poor
1a95a75b1a fix(persistence): adopt content only on legacy rows and stamp every inserted row
A legacy (no-digest) dict over a non-blank assistant row adopted the whole
decoded DB row: tool_calls / reasoning* / codex_* were overwritten with the
stored JSON (which still holds the escaped lone surrogate the sanitizer just
fixed, re-injecting it into the provider payload) and live-only fields were
popped. Resumed dicts (_rows_to_conversation stamps _row_id without a
digest) and compaction clones hit this path. Adopt content only, as before
this stack, via a content-only canonical handled like the metadata-only one.

_insert_message_rows dropped a clone's parent digest but only the flush
path restamped it, so clones made by archive_and_compact / replace /
rotation handoff / import reached the legacy path and the first live edit
after a clone was not persisted. Stamp the stored-row digest inside
_insert_message_rows (one batched SELECT, cold paths only; the flush path
statement count is unchanged) and drop the duplicate call in
append_messages_batch.

Define the _db_row_snapshot / _canonical_row keys once in
agent/message_metadata.py and import them everywhere instead of repeating
the literals.
2026-09-27 20:45:44 +05:30
kshitijk4poor
6e8aa00626 fix(persistence): version only owned columns so metadata writes keep row ownership
The row digest hashed every repair column, so a same-process metadata write
(reaction, display-kind stamp, api_content / codex reasoning backfill,
platform message id) made our own row look like a foreign winner. The
re-flush then adopted the stale DB row: a later live edit (the non-ASCII
strip recovery) was reverted, and unsanitized tool_calls/reasoning were
copied back onto the live dict.

The digest now covers only the owned (non-metadata) columns: it means "the
row is still what we last committed". Match -> write the live owned values
and hand over only presentation metadata the live dict lacks; mismatch ->
genuine other writer, adopt as before. The r3 "stored content equals the
durable form of live" special case is subsumed and removed.

Also:
- _insert_message_rows drops a carried digest when it assigns a new row id
  (compaction/replace/import clones carried the parent's version).
- append_messages_batch restores each message's _row_id / digest /
  timestamp and pops the adopted row at the top of every _execute_write
  attempt, so a rolled-back attempt cannot resolve to a foreign row.
- message_id is no longer synced onto live (int -> str flip, spurious
  platform_message_id).
- The JSONL divert strips both bookkeeping keys via one frozenset.
2026-09-27 20:45:44 +05:30
kshitijk4poor
ca7e4c1636 fix(persistence): keep live content on metadata-only row changes
Same-process writers (set_message_reaction, display-kind stamping, api_content
backfills, codex reasoning update) change stored columns after a flush without
refreshing the live row digest. The next sanitize + re-flush treated that as a
concurrent winner and copied the lossy durable projection over live multimodal
content, dropping image parts and shifting the prompt-cache prefix. On adoption
we now keep live content when the stored content is just the durable form of
it and sync metadata only; a real concurrent content winner is still adopted.

The legacy blank-assistant path (dict with _row_id but no digest, blank DB row)
again only fills the row from live content, as on main, instead of running the
full canonical sync that wiped live reasoning_content/finish_reason/tool_calls.
Adoption on the legacy path is limited to a non-blank row.

Also: transcript_row_snapshot returns str (the partial-row branch had no
caller), serialization only runs on the digest-match branch that reads it,
stamping reuses hermes_state_common._id_chunks, and _MESSAGE_WRITE_COLUMNS is a
plain top-level import (hermes_state_messages imports this module lazily, so
there is no cycle).
2026-09-27 20:45:44 +05:30
kshitijk4poor
1a43a4ef48 fix(persistence): strip the adopted canonical row from provider payloads
Gateway/TUI/CLI callers pass their live dicts straight to
append_messages_batch, so a concurrent-winner adoption leaves the
decoded durable row (_canonical_row) on a dict that may later be sent
to the model. Treat it as persistence-only like _row_id and the digest
so the outbound builder and token estimator both drop it.
2026-09-27 20:45:44 +05:30
kshitijk4poor
4f6ab19304 fix(persistence): keep live multimodal content and hash stored rows
The row-addressed repair stamped the decoded durable row on every
resolved message, so after our own rewrite the sync copied the lossy
durable projection (image parts -> "text\n[screenshot]") back onto the
live dict: multimodal user/tool messages lost their images and the
prompt-cache prefix changed. Adopt the DB row only when another writer
won (digest mismatch) or on the legacy assistant path, as BASE did.

The insert-time digest hashed Python bind values, but SQLite affinity
rewrites them on storage (int message_id -> TEXT, float token_count ->
INTEGER), so live and DB digests never matched and in-place edits were
silently dropped. Hash the stored rows instead, only on the
append_messages_batch flush path that reads the digest (one SELECT per
batch), incrementally (type tag + length prefix) instead of via JSON.
Also skip the no-op UPDATE, fix the _write_columns comment/spacing and
drop the duplicate top-level Optional import (F811).
2026-09-27 20:45:44 +05:30
kshitijk4poor
4854225903 fix(persistence): version transcript rows by digest, not a row copy
The CAS row snapshot was a full copy of each message's durable payload
riding on the live dict. The rough token estimator priced it (about 2x
estimates -> premature compaction) and it doubled transcript memory.

Replace it with a 16-byte blake2b digest of the repair columns. The
compare now runs in Python against the target row already read inside
the BEGIN IMMEDIATE transaction, followed by a plain UPDATE. Also:
- add _db_row_snapshot to PERSISTENCE_ONLY_MESSAGE_FIELDS so the
  estimator and the outbound request builder both drop it
- derive _REPAIR_COLUMNS/_SYNC_FIELDS from _MESSAGE_WRITE_COLUMNS
- use hermes_state_common._placeholders
- drop the dead resume-path stamp (the SELECT has no token_count, so it
  was always None) and the dead tool name assignment in
  _decoded_repair_row
- keep the digest out of divert JSONL

The kept active-row test now pins estimate stability across a flush and
the survival of a concurrent writer's row. It goes red on the old
prod files and red when the digest compare is removed.
2026-09-27 20:45:44 +05:30
kshitijk4poor
53e49c49dd fix(persistence): import Optional where transcript_repair uses it
transcript_row_snapshot annotates Optional, which was only reachable via the
PLUGIN-COMPAT re-export block at the bottom of the module. Internal code must
not depend on that revert-scheduled block, so import it with the other typing names.
2026-09-27 20:45:44 +05:30
Nagisa-3000
02b8c4a655 fix(persistence): preserve transcript row identity safely
(cherry picked from commit a1f28d54482eab32bd119fbc856c6e910c595ba9)
2026-09-27 20:45:44 +05:30
Nagisa-3000
e4e7837623 fix(persistence): repair transcript rows for all roles
(cherry picked from commit 7cebc297b145c0e3db007c1f05ea348a626fd38d)
2026-09-27 20:45:44 +05:30
Nagisa-3000
7581677da8 fix(persistence): repair inactive transcript rows in place
(cherry picked from commit 1d33010a27fbd7f053e1037269b6059edaef77ef)
2026-09-27 20:45:44 +05:30
kshitijk4poor
062dc1e7f0 refactor(context): drop redundant or-empty before redaction 2026-09-27 18:38:58 +05:30
kshitijk4poor
ab84dc8d57 fix(context): drop stale _shrink reference in marker comment
_shrink was deleted along with _truncate_tool_call_args_json, so the
comment pointed at code that no longer exists.
2026-09-27 18:38:58 +05:30
kshitijk4poor
fb86bc708d fix(context): redact full tool-call args before the summarizer cut
62ceddd342 cut raw args to HEAD+4096 before redaction to save time. The
PEM redaction pattern only matches a complete BEGIN...END block. A long key
whose END fell past the cut stayed unredacted, and once an earlier key was
redacted and the text shrank, its body landed in the 1200-char head that
goes into the persisted summary. Go back to the BASE order: redact the full
args, then apply the MAX/HEAD cut. This is a cold path (once per summarized
call per compaction), and _SUMMARY_INPUT_MAX_CHARS still bounds the prompt.
Extend the kept canonical-args test with a two-PEM input that leaks on
62ceddd342 and passes now.
2026-09-27 18:38:58 +05:30
kshitijk4poor
be834681dc fix(context): measure pruned regions, bound arg redaction, drop dead counters
Follow-ups to making tool-call args byte-exact:
- _record_compression_regions measured canonical_messages slices while
  compress_start/compress_end are indices into the pruned copy that head/tail
  are assembled from; measure the pruned rows actually sent, as before. This
  also removes the only canonical slicing, so blank-echo classification drift
  between the two copies can no longer misalign anything.
- _render_tool_call_for_summary redacted the full (now unbounded) args before
  cutting to 1200 chars; cut to head+4096 first. Output unchanged for args
  within that window.
- pressure_hits always equalled demoted once arg truncation left; fold it.
- Drop the fixture-only tautological assert in the guardrail test helper.
- Reword stale compress()/compression_marker docstrings that still described
  canonical head/tail and compressor-written arg markers.
2026-09-27 18:38:58 +05:30
kshitijk4poor
66240ddc59 fix(context): restore marker import in tests, drop unused compressor imports
The arg-truncation removal deleted the only uses of the marker constants in
agent/context_compressor.py (ruff F401), and the test module had been
importing _COMPRESSION_MARKER_PREFIX through it, so test_context_compressor
line 137 raised NameError. Import it from its home, agent.compression_marker.
2026-09-27 18:38:58 +05:30
kshitijk4poor
f6ce8bb23b fix(context): assemble compaction head/tail from the pruned copy (#61932)
The salvaged lossless-history change rebuilt the carried head/tail from
canonical history, which undid _pressure_demote_tail's tool-result
shrinking and re-broke #61932 (an all-oversized tail could no longer
compress). Pruning no longer rewrites tool_calls, so the pruned copy's
arguments are already byte-identical to canonical history: assemble the
head and tail from the pruned copy, keeping tool-result demotions and
exact tool-call arguments at once. Docs updated to match.
2026-09-27 18:38:58 +05:30
JoaoMarcos44
a7baa5f5eb fix(context): keep compaction history lossless
(cherry picked from commit d51c8f4f5096badfd0beddd78646617643f6028f)
2026-09-27 18:38:58 +05:30
kshitijk4poor
96cce6843d refactor(auth): drop impossible auth_store dict guard 2026-09-27 18:37:01 +05:30
kshitijk4poor
c8043a3630 fix(auth): drop redundant ownership auth.json reads in nous seeding and load_pool
_seed_nous_singleton re-read auth.json via _profile_owns_pool_provider even
though its only caller (_seed_from_singletons) just loaded the active store
and passed it in; check the passed auth_store through a shared
_store_owns_pool_provider predicate instead (same non-empty-list semantics,
also used by _profile_owns_pool_provider).

In load_pool, borrowing_root_grant repeated the guard that sets
owns_provider (non-None exactly when that guard holds), so test
`owns_provider is False` directly. The tail ownership re-read ran even
with no disk rows, where it could only assign set() -- the constructor
default -- so gate it on disk_ids and re-read only when _persist() ran.
Fix the stale "Computed once" comment.
2026-09-27 18:37:01 +05:30
kshitijk4poor
3dac1b3ae1 fix(auth): read flat refresh_token for forkable pool rows; reuse pool ownership in load_pool
_is_forkable_pool_row only ever receives flat credential_pool rows (the
strip loop over pool entries and heal_pool_rows over _pool_rows), so the
tokens-nesting fallback of _block_tokens was dead weight; read
refresh_token the same way _is_oauth_pool_payload does.

load_pool asked _profile_owns_pool_provider (an uncached auth.json read)
twice. Compute it once after the fork heal and reuse it for the
_borrowed_root_ids check unless _persist() rewrote the store in between
(that write can give the profile its own rows). _seed_nous_singleton keeps
its own call: threading the value through _seed_from_singletons would
change a signature that tests monkeypatch with fixed-arity fakes.
2026-09-27 18:37:01 +05:30
kshitijk4poor
e7bab8eb18 fix(auth): don't reseed root's nous grant into a profile that owns nous rows
A profile left with only an agent_key nous row after the fork strip/heal still
"owns" nous but has no local providers.nous block. The next load_pool('nous')
fell back to the global root block in _seed_nous_singleton and upserted root's
single-use refresh token into the profile pool, recreating the fork one load
later (both device_code and manual:* ak-row shapes). Skip seeding from the
global-root fallback when the profile owns local nous rows; borrowing profiles
(no local rows) are unaffected.

Also drop the redundant try/except around _global_auth_file_path() in
_profile_owns_pool_provider; that function already handles its own failures.
The existing nous strip test now reloads the pool after strip and asserts no
profile row carries root's refresh token.
2026-09-27 18:37:01 +05:30
kshitijk4poor
ddcd845993 fix(auth): skip auth.json re-read for pool ownership in classic mode
nous now takes the single-use path, so every load_pool('nous') (once per
message plus aux calls) re-parsed auth.json in _profile_owns_pool_provider.
In classic mode (_global_auth_file_path() is None) read_credential_pool has
no root fallback and persist_pool_entries cannot route to root, so the
answer is effectively always "owns": return early.

Also point the persist_pool_entries docstring at
SINGLE_USE_REFRESH_POOL_PROVIDERS and document why nous is deliberately
absent from _SINGLE_USE_REFRESH_PROVIDERS (own auth-store locking).
2026-09-27 18:37:01 +05:30
kshitijk4poor
622a296f7a fix(agent): keep the todo predicate off the model_tools/executor import path
is_todo_tool_call lived in agent/tool_executor.py and went through
canonical_tool_name, which imports model_tools. TUI resume calls it from
_todo_state_from_history on the RPC path, so the first resume in a
gateway loaded ~405 modules (2-3s) synchronously. tui_gateway/server.py
and run_agent.py also imported agent.tool_executor at module level,
adding ~142 modules to every TUI/desktop launch and breaking run_agent's
lazy-forward rule.

The predicate now lives in tools/todo_tool.py, which both startup paths
already load. It matches TODO_TOOL_NAMES ({TODO_SCHEMA name} + the legacy
aliases) and imports the bridge parser only when a tool_call entry's
args mention "todo". model_tools._LEGACY_TOOL_ALIASES derives its todo
entry from TODO_LEGACY_ALIASES, so there is one source of truth ("todo"
is the only alias mapping to todo_list). The live tool.complete path in
tool_progress uses is_todo_tool_name and the hand-kept _TODO_TOOL_NAMES
tuple is gone. The server.py noqa import is replaced by a function-local
import next to MAX_TODO_RESULT_CHARS, so a pruned name can't be swallowed
by the broad except. run_agent imports lazily. The dead TypeError arm is
dropped, and field reads use message_sanitization._tc_field.
agent/tool_executor.py is back to its pre-stack state.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-27 18:34:12 +05:30
kshitijk4poor
3d3c1c1223 fix(agent): pair bridged tool_call todo results via one shared predicate
todo_list is in the default tool_search defer list, so with tool search
active the model calls it through the tool_call bridge and the transcript
keeps function.name == "tool_call". The canonical-name pairing check never
matched those, so todos were still dropped across turns (#124960) in every
tool-search-active session, and the TUI resume snapshot had the same gap.

Add agent.tool_executor.is_todo_tool_call: canonicalizes legacy aliases and
peels the bridge from the recorded arguments with normalize_tool_call_entries
(exactly one entry required). It deliberately does not use
resolve_underlying_call, which reads live config and could disagree with the
defer list in force when the history was written. run_agent and
tui_gateway's _todo_state_from_history now share it; the canonicalizer is
public (canonical_tool_name) since it is now used across modules.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-27 18:34:12 +05:30
kshitijk4poor
ad4e4496c2 fix(compression): drop duplicated snapshot sentence from task prompt
The historical_task instructions already explain that the compressor inserts
a bounded, redacted snapshot after generation; repeating it in the reverse
signal paragraph only adds prompt tokens.
2026-09-27 18:27:37 +05:30
Charan Rathore
f7be32556d fix(compression): avoid long verbatim task quotes in summaries
(cherry picked from commit 006e1d286126835124c3d837c5bd094291c89716)
2026-09-27 18:27:37 +05:30
Adolanium
e46d4c0ade fix(compression): let the next summary build on a fallback handoff
A deterministic fallback summary replaced the older handoff in the transcript but never updated _previous_summary. The next compaction kept the stale in-memory summary and dropped the fallback row from its window, so the fallback's user asks, files and last dropped turns never reached the summarizer. Store the fallback body in _previous_summary the same way a normal summary is stored.

(cherry picked from commit 35417d2e1ffbb775c3eaff17b26623896afa56c1)
2026-09-27 18:20:40 +05:30
kshitijk4poor
d8be403097 fix(compression): match the Codex stall marker on the raw error text
The stall check relied on the lowercased error string, which only works
while CODEX_STREAM_STALL_MARKER happens to be all-lowercase. Match against
str(e) so the shared marker stays authoritative regardless of case, and
fold the two duplicate #124077 comments into one explaining the split
(stall -> retry-ladder timeout; transport timeouts stay terminal).
2026-09-27 18:19:07 +05:30
kshitijk4poor
8c9f2162c7 refactor(compressor): key the Codex stall on the stream guard's shared marker
Co-authored-by: happy5318 <5318happy@users.noreply.github.com>
(cherry picked from commit 83c0a8bb9c7d0e611bafe436d2e4da77144c26ec)
2026-09-27 18:19:07 +05:30
kshitijk4poor
3a1a45dbf1 fix(compression): keep transport timeouts terminal; reclassify only the Codex stall
The cherry-picked fix set streaming_closed=False for every timeout, which
also stripped the terminal abort-and-preserve-session behaviour from real
network timeouts (openai APITimeoutError, httpx Read/ConnectTimeout), the
deliberate #29559/#25585/#94448 design. Narrow it: only a TimeoutError whose
message says "stalled" (the Codex aux stream guard) becomes a timeout that
takes the 60/300/900s ladder and does not arm _last_summary_network_failure.
Other timeouts classify exactly as on main.

Co-authored-by: happy5318 <5318happy@users.noreply.github.com>
(cherry picked from commit e013c38a017b4709d4598a0a07c71ea26519312c)
2026-09-27 18:19:07 +05:30
happy5318
56ee7d6f09 fix(compression): classify a Codex stream-guard stall as a timeout, not a terminal network failure (#124077)
## Thinking Path
When the Codex auxiliary stream guard aborts a compaction summary mid-stream
it raises `TimeoutError("Codex auxiliary Responses stream stalled: no new
output for 60.0s ...")`. The message contains neither "timeout" nor
"timed out", so `_classify_summary_failure` returned `timeout=False` while
`_is_connection_error` (which matches the type name "Timeout") returned
`streaming_closed=True`. The terminal network-failure flag then armed an
unconditional abort (`_TERMINAL_SUMMARY_FAILURES`), bypassing the retry
ladder and the deterministic fallback summary — on turn-start preflight
compression that ends in "Auto-resetting session after compression
exhaustion", wiping the session.

### What Changed
`agent/context_compressor.py` `_classify_summary_failure`:
- `timeout` is now computed first, and additionally matches `isinstance(e,
  TimeoutError)` and the "stalled" message shape (the actual text the Codex
  guard emits).
- `streaming_closed` is `_is_connection_error(e) and not timeout` — a
  timeout keeps its retry-ladder semantics and can never arm the terminal
  network-failure abort.

### Tests
New `TestSummaryFailureClassification124077` in
`tests/agent/test_context_compressor.py`:
- classify: Codex stall → `timeout=True, streaming_closed=False`.
- classify: plain `ConnectionError` stays `streaming_closed=True` (no
  regression on the premature-close class).
- classify: a "timed out" message on a non-TimeoutError type stays a
  timeout and is excluded from `streaming_closed`.
- integration: the stalled-summary path in `_generate_summary` does NOT arm
  `_last_summary_network_failure`.

### Verification
- RED/GREEN double proof via git stash: pre-fix 3 failed, post-fix 4/4 pass.
- Regression: 15 existing failure-classification tests pass
  (network_failure / premature_stream / empty_content / auth / truncation).
- ruff clean on changed files.

### Notes
Local test env: this checkout's venv python is a symlink into
`<home>/hermes-agent/.hermes-runtime/...`, so stdlib `zoneinfo` first-import
and pydantic's plugin `distributions()` scan under the real-home IO guard
needed the collection-time warmups at the top of the test file. CI
interpreters are not symlinked into the home — those two warm-up blocks are
no-ops there.

## Related
#124077 (issue). Family: #124078 (the same stall's template trigger),
#108104 (`auxiliary.compression.no_progress_timeout`).

(cherry picked from commit c5399fbdee44f2db1172f22632cbf348a2c7cab5)
(cherry picked from commit bce26a99791c09911d8a008a1922c512f5c1fcea)
2026-09-27 18:19:07 +05:30
kshitijk4poor
0d9329bd95 test(agent): fold async handoff test and dedupe rationale comment
The byte-stability test for the handoff block only re-asserted determinism of
a constant gated on tool-name membership; stable-tier rebuild stability is
already covered by test_system_prompt_restore and test_skills_auto_load. Its
one unique check (block appears exactly once) moves into the positive branch
of the parametrized injection test, and the _prompt helper now takes only the
tool names since every caller used the same model/gates.

The rationale comment lived twice (prompt_builder constant and the
system_prompt call site); keep only the call-site ordering note.
2026-09-27 18:18:12 +05:30
JoaoMarcos44
96a8cecd39 fix(agent): scope async yield guidance to delegation
(cherry picked from commit 99f82de0f99b055bdf5a5ec15be5e662934f2172)
2026-09-27 18:18:12 +05:30
JoaoMarcos44
d9ef15dd3c fix(agent): allow async delegation handoff to end turns
(cherry picked from commit 1378fa1b289ead3b2f8db582353eb4b1574c3d51)
2026-09-27 18:18:12 +05:30
jackulau
9f5440e23d fix(agent): report the conversation category even when it is empty
The category filter dropped every zero-token category from the breakdown
payload. For mcp/memory/skills that is right — zero means "not
configured" and the row's absence says so. For conversation it hid the
row on any session whose transcript was empty or pre-turn, so the
Desktop Context usage panel showed System prompt/Tools/Memory but no
Conversation until the first turn completed (#87903): "the transcript
is empty" rendered identically to "the breakdown never measured it".

Zero for the conversation is a MEASUREMENT of something every session
has, so it is exempted from the drop via _ALWAYS_REPORTED; the
membership rule is documented at the constant so later additions argue
from the same principle. Structurally absent categories stay dropped.

Test: an empty-transcript breakdown reports conversation at 0 while
unconfigured optional categories remain omitted.

The desktop half (retained pre-turn snapshot) is already fixed on main:
useContextBreakdown nulls the snapshot mid-turn and the statusbar gauge
falls back to the streamed usage.

Python half salvaged from PR #87925 (author credited).

Fixes #87903
2026-09-27 06:54:56 -05:00
austinpickett
516535b542 fix(agent): name an exhausted (402) provider pool, not "No LLM provider configured"
Fixes #94785
2026-09-27 04:59:47 -04:00
Brooklyn Nicholson
9b637e37ff fix(agent): de-glue summary-part boundaries in the live reasoning display
Routing the live display through reasoning_details bypassed
separate_glued_reasoning_blocks, so a reasoning-summary model's
head-to-tail bold headings glued into '**One****Two**' in the live box
while the persisted reasoning_content stayed repaired. De-glue the
detail-derived text against its own display accumulator (not
reasoning_parts — mirrored fields would insert a spurious break on the
first chunk), so display and history agree.
2026-09-27 02:48:48 -05:00
Wenfengcheng
cd582e63fd fix(agent): deliver readable reasoning details during streaming
The streaming reasoning display read only reasoning_content/reasoning deltas;
reasoning_details deltas were accumulated for replay continuity but their text
was never routed to the live display, so models that stream thought solely via
reasoning_details (xiaomi/mimo-v2.6-pro via OpenRouter) showed no thinking
display at all. The non-streaming extract_reasoning already reads detail text —
this closes the asymmetry.

One representation per chunk is emitted: detail text when the details carry
it, otherwise the plain reasoning field. Persisted/replayed fields are not
rewritten; opaque replay material (encrypted/unknown types) stays unexposed.
A callback that raises (or is None) no longer breaks the stream.

Fixes #118851
Salvaged from #118888 by Wenfengcheng
2026-09-27 02:48:48 -05:00
Hermes Agent
c42c90552e fix(agent): re-gate later terminal approvals after an earlier batch failure (#113158)
Desktop terminal batching pre-collects the approval for every command in
a run before any of them executes, so the user consents to a batch in
which all commands are expected to run. When an earlier command then
fails (or is denied/blocked), that informed consent no longer describes
the world the later commands will run in — yet the executor still
consumed the pre-made decision as though nothing had happened.

- _TerminalBatch.failure_seen: set by the sequential publisher after a
  slot's failed (or blocked) result is committed — i.e. after the failure
  the model actually sees, never from a wedged worker's late result.
- consume_prepared_guard drops the prepared decision for any later slot
  once a failure is published and returns None, so the guard runs its
  live flow again: tirith scan, allowlist, and a fresh human approval
  request when the command still warrants one. Nothing is auto-denied
  and an explicit denial of the failing command remains authoritative.
- The flag is sticky for the batch: a later success must not un-stale an
  approval collected before an even earlier failure.

Success-path batching is unchanged: with no failure, prepared decisions
are consumed exactly as before (byte-for-byte the same flow).
2026-09-26 21:00:46 -05:00
Hermes Agent
b90b7ae7ed fix(delegate): title subagent sessions after their goal without a model call 2026-09-26 20:40:40 -05:00
Hermes Agent
ce750ff151 fix(title): strip a quoted path's line-range suffix in the attachment-only guard
A quoted (space-bearing) @file:/@folder: reference may carry a :start[-end]
line range; the canonical parser (context_references.REFERENCE_PATTERN)
claims the whole token. The guard's local copy stopped at the closing quote,
leaving ":3" as residual "prose", so a quoted, line-ranged attachment-only
opener kept a path-derived title (#92068 regression found in review of #122000).
Mirror the canonical value shape and pin all three quote styles (#122000).
2026-09-26 20:29:47 -05:00
Brooklyn Nicholson
afad9bb8ec fix(title): an attachment-only opener stays untitled, not @file: path garbage (#92068)
The manual-attach path (composer attach chip / hand-typed @file:) sends no
Desktop paste preview, so the build_title_input ref-only shortcut never fires
and derive_title names the session after the truncated file path
(title_source='derived' DB rows from the issue).

An opener that reduces to nothing but @file:/@folder: context references
(and their expansion footer) is a file drop, not a request: refuse it at
every title entry point — is_titleable_user_message, derive_title, and
generate_title (reached via auto_title_session, which lacks the
instant-title guard). Prose around an attachment keeps titling from the
prose; the paste-preview path is untouched.

Co-authored-by: andyst-dev <andy@example.com>
Co-authored-by: fangliquanflq <fangliquan@example.com>
2026-09-26 20:29:47 -05:00
finn763
a164569429 fix(agent): admit and sync gateway-staged attachments on remote execution backends (#110174)
Desktop paste/file attachments land in Hermes-managed staging dirs on the
GATEWAY (composer-pastes/ for large text pastes, attachments/ for dropped
files), but on the Remote SSH topology the workspace root (TERMINAL_CWD) is a
path on the SSH HOST - the two filesystems are fully disjoint, as the issue
thread confirms. Two gaps combined to reject every staged attachment with
"path is outside the allowed workspace":

- _resolve_path admitted only allowed_root + composer-paste roots, so a
  gateway-staged attachments/ path was refused outright. Admit the
  _CACHE_DIRS staging roots (attachments/, images/, cache/*, composer-pastes/)
  via a helper that asks get_cache_directory_mounts - the gateway's OWN
  payload is never a workspace escape, and the path-traversal and
  credential-deny guards in _ensure_reference_path_allowed still run after.
  Anything else outside the workspace stays blocked.
- composer-pastes/ was missing from _CACHE_DIRS, so its bytes never reached
  the remote: ssh/daytona/vercel_sandbox sync via iter_sync_files ->
  iter_cache_files, and to_agent_visible_cache_path only translates mounted
  dirs - a paste attached on a fresh session dangled on the remote host.

Tests cover the disjoint-filesystem SSH topology end-to-end (text inlines,
binary renders the synced ~/.hermes path), the still-refused stranger path,
local-backend unchanged, and the composer-pastes mount+sync enumeration.

Consolidates PR #110387 by Finn763 (the _agent_staged_path guard widening and
the SSH-topology tests, adapted to the current _ensure_reference_path_allowed
ordering) with PR #103412 by ericmaddox (whose mapping insight is subsumed by
the _CACHE_DIRS entry, which fixes both the sync and the translation).

Co-authored-by: ericmaddox <ericmaddox@users.noreply.github.com>
2026-09-26 18:00:17 -05:00
Brooklyn Nicholson
ac80df1410 fix(credits): stop desktop reap/resume from re-announcing the same usage band
Desktop rebuilds the AIAgent per turn (idle reap -> next message re-mint),
and agent_init.py gives every rebuilt agent a brand-new, empty _credits_latch
via new_credits_latch(). seed_credits_at_session_start() -> _hydrate_seed_state()
then unconditionally primes latch["seen_below_90"] on that fresh latch and
evaluates once -- correct for a genuinely new session opening mid-band, but on
a reap/resume rebuild it makes evaluate_credits_notices() see
shown_band=None vs. current_band=<the same band as before>, so it re-fires
"You've used $X of your $Y cap" on every message even though the user already
saw that exact notice moments ago on the previous incarnation of the same
session (#101578).

agent.session_id is stable across these rebuilds even though the agent object
and its latch are not, so add a small process-lifetime cache
(_seen_usage_bands, bounded to 500 entries, MRU eviction) keyed by session_id
that remembers the last usage_band actually shown. _hydrate_seed_state()
restores it into the fresh latch before evaluating, so a rebuild with unchanged
usage stays quiet, while a rebuild after a genuine crossing (recorded via the
same warm-path write in rate_limit_credits._emit_credits_notices, the single
chokepoint both the seed and live-header paths share) still fires normally.
Deliberately not persisted anywhere durable -- a real process restart is a real
"session open" and should still warn immediately, matching the existing
cold-start seed behavior for a session that opens already in a band. An agent
with no session_id (plain CLI, never rebuilt) falls back to the pre-fix
behavior unchanged.

Tests (tests/agent/test_credits_cold_start.py): a rebuild with the same
session_id and unchanged usage does not re-fire; a rebuild after a genuine
band change still fires (and clears the old key); a different session_id is
never suppressed by another session's history; an agent without a session_id
degrades to the old always-prime behavior without raising.

Fixes #101578

Co-authored-by: Edizzier <umit.ediz@hotmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 17:59:52 -05:00
Hermes Agent
890db53396 fix(agent): clamp displayed context usage to the model window 2026-09-26 17:15:30 -05:00
Ahmett101
9f27e75a77 fix(agent): invalidate usage anchors after prefix rewrites 2026-09-26 17:15:30 -05:00