fix(agent): row-addressed api_content backfill for pre-persisted user turns (#102194) - #102411
JoaoMarcos44 wants to merge 1 commit into
Conversation
Related: competing fixes for #102194 -- #102239 (earliest, unconditional positional backfill) and #102286 (gates on |
e57a9d7 to
87c2fb9
Compare
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head 87c2fb9616a49e66f498da8b3d93ac2dffc422b5 against current main@63279301bcbdc185c1b07b98a9312eb0c862f26d, the #102194 incident, and the competing #102239/#102286 carriers.
The semantic repair is materially stronger than both predecessors. The early-writer proof is real: _insert_message_rows() stamps _row_id on inserted dicts, and sync_flushed_message_markers() copies the committed _row_id back onto live flushed dicts. This head then uses that durable identity as both existence proof and update address; the normal path performs no positional backfill. The repeated-content regression is the important inverse witness: an older ok row cannot be reached when updating the current ok row. The boolean/non-positive row-id guards and content/session/active predicates are also appropriately fail-closed.
The sole surviving commit is exact-head green on CI 33797399429, Docker 33797397032, and Nix 33797396886.
One P1 landing blocker remains: this head adds 51 lines directly to hermes_state.py, which is still a godfile far above the repository's 2,000-line ceiling. #102117 is already the active structural owner for this surface and records hermes_state.py at 17,370 lines on main, with its decomposition moving that surface into bounded hermes_state_* modules. This PR also overlaps #102117 in the state/turn-context area, so merge order cannot be implicit.
Please keep the row-addressed semantics exactly as written, but move the new store primitive into a bounded state owner rather than growing hermes_state.py. Either compose after #102117's own blockers are cleared and place the method in the extracted state owner, or perform the smallest compatible state extraction here and interlock it explicitly with #102117. Then reacquire CI/Docker/Nix on the recomposed single-commit object.
Topology: #102411 is the correct successor carrier for #102194; #102239's unconditional positional write and #102286's persisted-marker-only positional write should not land independently.
Sahilvishnaliya
left a comment
There was a problem hiding this comment.
Independent verification (Windows/py3.12, this PR's head)
Tests: tests/agent/test_api_content_row_addressed_backfill.py + tests/agent/test_api_content_sidecar.py — 38/38 pass.
This is the right design among the three candidates for #102194, and the test suite is genuinely adversarial. Verification notes:
- Row-addressed
set_message_api_contentis the correct primitive: the positionalset_latest_user_api_content"newest active user row" match is provably unsafe for this backfill — a repeated user turn ("ok"/"y"/"continue") makes the previous turn's row compare equal on content, so the positional update would overwrite that turn's sidecar with this turn's bytes and replay wrong bytes forever. The docstring added toset_latest_user_api_contentdocumenting exactly this hazard is good durable documentation. - The
_row_idgate is the correct arming condition and the tests pin every arm/not-arm edge: no row id + no compaction → no write (normal path, crash persist carries the sidecar in the INSERT);_db_persistedalone does NOT arm (resumed-history dicts whose row id is unknown — would re-open the wrong-row write); boolean row ids rejected (isinstance(True, int)); row id wins over the positional compaction fallback; compacted-copy-without-row-id keeps the positional fallback (safe there becausearchive_and_compactjust made it the newest active user row). - Store-level guards verified by tests: wrong session, wrong content, unknown row, archived (
active = 0) row all return 0.
Two questions for the author/maintainers, neither blocking:
content IS ?+ surrogates.set_message_api_contentmatches on the encoded content. If a previous writer stored the row through a path that scrubbed surrogates and the live dict still carries them (or vice versa), the guard returns 0 and the sidecar is silently dropped again — the exact class of "cache-stability invariant silently broken" the issue documents. Thetest_survives_lone_surrogatetest covers the write side; is there a known path wherecontentbytes at stamp time differ from what the early writer inserted?sync_flushed_message_markerscoverage. The prologue test arms via_pending_cli_user_message+_row_id, and the docstring says both early writers stamp_row_id(_insert_message_rows,sync_flushed_message_markers). I couldn't find a test that goes through the actual early-flush path (close()/early flush racing the prologue) to prove_row_idreally is synced onto the live dict in that race — the mocked tests assume it. If that sync exists on main, a one-line pointer to it would close the loop; if it doesn't, this PR fixes compaction + assumes the flush path.
Relative to the alternatives: #103565 also targets this issue — worth maintainers comparing arming conditions (the wrong-row hazard above is the decisive difference; this PR is the only one that structurally cannot write a neighbour turn's sidecar).
Ran nothing else beyond the listed suites; the PR touches the turn prologue's write path, so the standard broader tests/agent/ selection on CI should be watched.
salch-cred
left a comment
There was a problem hiding this comment.
Follow-up to my earlier verification: base staleness vs #103565 (maintainer triage note)
The content of this PR is correct and its tests pass (38/38 here, see my earlier comment), but triage should know the two #102194 candidates are not interchangeable right now:
Base staleness + file split conflict. This PR's base is 63279301bc — 202 commits behind current main. Its store hunk adds set_message_api_content directly to hermes_state.py immediately after set_latest_user_api_content at the old L~13636 position. Since that base, main has split the monolithic hermes_state.py into mixin modules; set_latest_user_api_content now lives in hermes_state_messages.py (SessionMessagesMixin), and hermes_state.py no longer contains it. The hunk's context lines no longer exist in the file it patches — GitHub currently reports this PR mergeable: UNKNOWN, and a naive textual merge will conflict or silently drop the store method into the wrong module.
#103565 (opened today) implements the same row-addressed design — same set_message_api_content semantics, same _row_id-or-in_place arming gate, same positional-fallback restriction, same wrong-row ("repeated ok/y/continue") rationale — but patches the correct post-split module and is mergeStateStatus: CLEAN.
My suggestion: rebase this PR onto current main (the method belongs in SessionMessagesMixin in hermes_state_messages.py next to set_latest_user_api_content), because this PR's test suite is materially stronger — 281 lines covering store guards (wrong session/content/row, archived rows, surrogates, boolean/invalid ids) and prologue arming edges (_db_persisted must not arm, boolean row id must not arm, row-id wins over compaction fallback), vs. the two tests in #103565. Whichever lands, the other's tests are worth carrying over; the underlying design is identical and both verify green locally (mine: 38/38 for this PR, 3/3 + broader -k api_content 33 pass for #103565).
ehz0ah
left a comment
There was a problem hiding this comment.
I tested exact head 87c2fb9616a49e66f498da8b3d93ac2dffc422b5. The focused sidecar suites pass 38/38, and row-addressed storage correctly protects an older identical input. One affected path is still incomplete.
A close flush can persist the staged clean input before build_turn_context restores an API-only variant such as a voice prefix or model-switch note. I reproduced this through the real _flush_messages_to_session_db path. When no memory or plugin context is added, compose_user_api_content returns None, so line 1627 never stores the API-only bytes. When plugin context is added, the row-addressed update uses the restored API text as its content guard, while the row contains the clean persistence override, so the update affects zero rows. Both cases leave api_content null and the next turn replays different bytes.
The backfill needs to derive the durable content guard with the same persistence-override rule used by the flush path. It also needs to preserve a differing API-only payload when no new memory or plugin context is composed. Regression tests should run the actual early flush before the prologue rather than constructing a row and _row_id directly.
This head also conflicts with current main in agent/turn_context.py and hermes_state.py. The state method now belongs in SessionMessagesMixin in hermes_state_messages.py. PRs #103565 and #103721 are current-tree carriers for the same design, but both currently retain the API-only gap above.
… turns (NousResearch#102194) The api_content sidecar ('persist what you send') preserves prompt-cache stability across turn boundaries by persisting the exact API-bound bytes (including memory-manager prefetch, plugin injections, and API-only notes) and substituting them on replay. When a user turn was already materialized in the database before the sidecar could be composed (in-place preflight compaction or a close/early flush racing the prologue on the CLI path), the turn-start crash persist marker-skips that message. Previously, the backfill was gated strictly on in-place compaction (_preflight_compressed and _last_compaction_in_place), so racing CLI flushes left api_content = NULL in SQLite and broke prompt caching on subsequent turns (NousResearch#102194). Positional approaches (such as NousResearch#102239 and NousResearch#102286) using LIMIT 1 on the newest active user row are unsafe: repeated common inputs ('ok', 'yes', 'continue') cause the backfill to match and overwrite the PREVIOUS turn's row with the new turn's sidecar, corrupting history and breaking cache parity. Resolve all landing blockers and review feedback from NousResearch#102411: 1. Bounded state owner (Sahilvishnaliya): Add SessionDB.set_message_api_content(session_id, row_id, content, api_content) to SessionMessagesMixin in hermes_state_messages.py instead of growing hermes_state.py. Update set_latest_user_api_content docstring with durable warning on the positional hazard. 2. API-only turns & durable content selection (ehz0ah): When a pre-flushed clean input has an API-only difference (e.g. voice prefix or model-switch note): - Retain the differing API-facing bytes as api_content even when no new memory or plugin context was injected. - Derive the durable content guard using _override_replaces_content so the SQL 'content IS ?' guard matches the clean override text stored in the DB row rather than the restored wire text. 3. Turn prologue gating (_row_id): In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on the live user dict (stamped by _insert_message_rows and synced by sync_flushed_message_markers). If valid (positive int, not bool), address by exact ID. If absent but in-place compacted, fall back to positional update. On normal turns, skip the backfill entirely (single atomic INSERT). 4. Real lifecycle test coverage (salch-cred, ehz0ah): Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py covering store guards, surrogate scrubbing, gate non-arming, older identical row protection, real close-flush row_id synchronization, and API-only clean override preservation with exact wire replay. Fixes NousResearch#102194. Closes NousResearch#102411.
87c2fb9 to
fc6b79e
Compare
Response to Reviewers & Follow-Up Audit Report (#102411)Thank you @Sahilvishnaliya, @salch-cred, @ehz0ah, and @alt-glitch for the thorough code reviews, deep adversarial testing, and constructive guidance on this PR. Your insights were instrumental in making this solution complete, robust, and aligned with the repository's architecture. Summary of Changes Implemented
4-Round Deep Investigation: Potential Bottlenecks & Blocking HazardsFollowing the requested audit, four rigorous investigative passes were conducted across the codebase to identify any bottlenecks, concurrency races, cache invalidations, or DB locking issues: Round 1: Concurrency, Asynchronous Races & Lifecycle Edge Cases
Round 2: Prompt Cache Invariants, Wire Format & Replay Consistency
Round 3: Compaction, Rotation, and History Rewrites
Round 4: Performance, Storage Footprint & SQLite Execution Plan
Verification ResultsAll tests executed with Python 3.11 / Pytest 9.1: # 1. Row-addressed backfill test suite (including store guards, surrogates, and real lifecycle tests)
pytest tests/agent/test_api_content_row_addressed_backfill.py -v
============================= 15 passed in 12.74s =============================
# 2. Existing sidecar test suite
pytest tests/agent/test_api_content_sidecar.py -v
============================= 27 passed in 35.00s =============================
# 3. Turn context suite
pytest tests/agent/test_turn_context.py -v
============================= 20 passed in 4.72s ==============================
# 4. Lint and code shape
ruff check agent/turn_context.py hermes_state_messages.py tests/agent/test_api_content_row_addressed_backfill.py
All checks passed!Commit |
… turns (NousResearch#102194) The api_content sidecar ('persist what you send') preserves prompt-cache stability across turn boundaries by persisting the exact API-bound bytes (including memory-manager prefetch, plugin injections, and API-only notes) and substituting them on replay. When a user turn was already materialized in the database before the sidecar could be composed (in-place preflight compaction or a close/early flush racing the prologue on the CLI path), the turn-start crash persist marker-skips that message. Previously, the backfill was gated strictly on in-place compaction (_preflight_compressed and _last_compaction_in_place), so racing CLI flushes left api_content = NULL in SQLite and broke prompt caching on subsequent turns (NousResearch#102194). Positional approaches (such as NousResearch#102239 and NousResearch#102286) using LIMIT 1 on the newest active user row are unsafe: repeated common inputs ('ok', 'yes', 'continue') cause the backfill to match and overwrite the PREVIOUS turn's row with the new turn's sidecar, corrupting history and breaking cache parity. Resolve all landing blockers and review feedback from NousResearch#102411: 1. Bounded state owner (Sahilvishnaliya): Add SessionDB.set_message_api_content(session_id, row_id, content, api_content) to SessionMessagesMixin in hermes_state_messages.py instead of growing hermes_state.py. Update set_latest_user_api_content docstring with durable warning on the positional hazard. 2. API-only turns & durable content selection (ehz0ah): When a pre-flushed clean input has an API-only difference (e.g. voice prefix or model-switch note): - Retain the differing API-facing bytes as api_content even when no new memory or plugin context was injected. - Derive the durable content guard using _override_replaces_content so the SQL 'content IS ?' guard matches the clean override text stored in the DB row rather than the restored wire text. 3. Turn prologue gating (_row_id): In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on the live user dict (stamped by _insert_message_rows and synced by sync_flushed_message_markers). If valid (positive int, not bool), address by exact ID. If absent but in-place compacted, fall back to positional update. On normal turns, skip the backfill entirely (single atomic INSERT). 4. Real lifecycle test coverage (salch-cred, ehz0ah): Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py covering store guards, surrogate scrubbing, gate non-arming, older identical row protection, real close-flush row_id synchronization, and API-only clean override preservation with exact wire replay. Fixes NousResearch#102194. Closes NousResearch#102411.
fc6b79e to
b0aaafa
Compare
ehz0ah
left a comment
There was a problem hiding this comment.
Reviewed exact head b0aaafab53b62251f14c4dd36257d3c5457937c7. The core row-addressed repair and the API-only follow-up are sound. The focused and adjacent suites passed 64 tests on the exact head and after a clean merge with current upstream/main at 2237be355906fbe6065ce1815711eee52b2d646e. Ruff, compatibility-pointer checks, and diff whitespace checks pass. Mutation probes also confirmed that the new API-only and durable-content regressions fail when either load-bearing branch is removed.
One blocking problem was added in the latest force-push. It is documented inline.
| _db.set_message_api_content( | ||
| agent.session_id, _row_id, durable_content, _api_content | ||
| ) | ||
| elif hasattr(_db, "set_latest_user_api_content"): |
There was a problem hiding this comment.
This fallback discards the exact row identity and reintroduces the positional corruption this PR is designed to remove. I reproduced it with a wrapper that exposes only set_latest_user_api_content and delegates to a real SessionDB: with repeated ok rows and _row_id=1, this branch updated the newer row at id 3 while leaving id 1 unchanged. The only in-tree runtime store is SessionDB, and this PR adds the exact method there. If an external or stale wrapper lacks it, skip or fail closed instead of making a wrong-row write. The new duck-typed-store test should assert that the positional method is not called when a valid _row_id exists.
There was a problem hiding this comment.
Thank you @ehz0ah for catching this! Your reproduction of the wrapper delegating to \set_latest_user_api_content\ with repeated \ok\ turns perfectly pinpointed the hazard.
We have resolved this in commit \�d844e0ad5:
- Removed the positional fallback when _has_valid_row_id\ is True: If an external or legacy store wrapper lacks \set_message_api_content, the update now fails closed (skips without writing) instead of falling back to positional matching. The positional updater is strictly restricted to _in_place_compacted\ where no row ID exists and the row is guaranteed to be the newest active user row.
- Updated and Added Adversarial Tests:
- Updated \ est_duck_typed_store_does_not_fall_back_to_positional_when_row_id_present\ in \ ests/agent/test_api_content_row_addressed_backfill.py\ to assert \mock_db.set_latest_user_api_content.assert_not_called().
- Added \ est_wrapper_lacking_set_message_api_content_fails_closed_without_corrupting_newer_row\ reproducing your exact adversarial wrapper scenario with repeated \ok\ turns at row ID 1 and 3. Verifies that row 3 is not corrupted and row 1 remains untouched.
… turns (NousResearch#102194) The api_content sidecar ('persist what you send') preserves prompt-cache stability across turn boundaries by persisting the exact API-bound bytes (including memory-manager prefetch, plugin injections, and API-only notes) and substituting them on replay. When a user turn was already materialized in the database before the sidecar could be composed (in-place preflight compaction or a close/early flush racing the prologue on the CLI path), the turn-start crash persist marker-skips that message. Previously, the backfill was gated strictly on in-place compaction (_preflight_compressed and _last_compaction_in_place), so racing CLI flushes left api_content = NULL in SQLite and broke prompt caching on subsequent turns (NousResearch#102194). Positional approaches (such as NousResearch#102239 and NousResearch#102286) using LIMIT 1 on the newest active user row are unsafe: repeated common inputs ('ok', 'yes', 'continue') cause the backfill to match and overwrite the PREVIOUS turn's row with the new turn's sidecar, corrupting history and breaking cache parity. Resolve all landing blockers and review feedback from NousResearch#102411: 1. Bounded state owner (Sahilvishnaliya): Add SessionDB.set_message_api_content(session_id, row_id, content, api_content) to SessionMessagesMixin in hermes_state_messages.py instead of growing hermes_state.py. Update set_latest_user_api_content docstring with durable warning on the positional hazard. 2. API-only turns & durable content selection (ehz0ah): When a pre-flushed clean input has an API-only difference (e.g. voice prefix or model-switch note): - Retain the differing API-facing bytes as api_content even when no new memory or plugin context was injected. - Derive the durable content guard using _override_replaces_content so the SQL 'content IS ?' guard matches the clean override text stored in the DB row rather than the restored wire text. 3. Turn prologue gating (_row_id) & fail-closed store duck-typing (ehz0ah): In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on the live user dict (stamped by _insert_message_rows and synced by sync_flushed_message_markers). If valid (positive int, not bool), address by exact ID. Do NOT fall back to positional matching when a row ID is present: if an external or custom wrapper lacks set_message_api_content, fail closed and skip rather than corrupting a neighbouring row. If absent but in-place compacted, fall back to positional update. On normal turns, skip the backfill entirely (single atomic INSERT). 4. Real lifecycle test coverage (salch-cred, ehz0ah): Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py covering store guards, surrogate scrubbing, gate non-arming, older identical row protection, real close-flush row_id synchronization, API-only clean override preservation with exact wire replay, and duck-typed store fail-closed verification when set_message_api_content is absent. Fixes NousResearch#102194. Closes NousResearch#102411.
b0aaafa to
bd844e0
Compare
|
Thank you @ehz0ah for the thorough review and catching the duck-typed wrapper fallback regression! We have pushed an amended commit ( 1. Resolution of the Inline Feedback
2. Investigation on Potential Bottlenecks & Blockers (4 Rounds)Following your review, we performed an extensive 4-round audit across related subsystems:
All 45 focused tests ( |
There was a problem hiding this comment.
Request changes conclusion (repository permissions block formal review)
Motivation
The problem reported in #102194 is valid. CLI shutdown or early persistence can write the current user message before plugin or memory context produces its api_content. If the sidecar is not backfilled into the same database row, the next resume replays different request bytes and breaks the prompt cache prefix.
Approach
This PR uses the persisted _row_id as proof that the current message is already stored and as its exact address. It adds set_message_api_content. This is the correct direction. It avoids selecting the newest user row by matching text alone and fixes the path where API-only content differs from the persisted override. The latest commit also removes the unsafe positional fallback from the legacy wrapper.
Specific changes
agent/turn_context.py computes the content that is sent and the content that belongs in the database, then prefers an exact _row_id backfill. hermes_state_messages.py adds an update protected by row ID, session, role, active state, and content. New tests cover duplicate input, invalid row IDs, archived rows, real early flush, API-only content, persisted overrides, and fail-closed behavior when an old wrapper lacks the new method.
Risk to main
One P1 race remains. _stamp_api_content_sidecar reads _row_id before _persist_turn_start acquires _session_persist_lock. A shutdown thread can already hold that lock and can have copied a database row without the sidecar, while not yet committing it or writing _row_id back to the message. The turn prologue then sees no _row_id and skips the backfill. The shutdown thread commits api_content = NULL and marks the message as persisted. Turn-start persistence later skips that message. Resume still replays the wrong bytes. The inline comment contains the exact reproduction and repair direction.
Validation at exact head bd844e0ad5ac8eb20e6b4bd4c15f7764c129703a: all 18 tests in the new regression file passed, and 47 existing sidecar and turn-context tests passed. Ruff, compatibility-pointer checks, and the diff whitespace check passed. Exact-head GitHub CI reported 24 successful checks and 0 failures. Both new regressions fail when the old positional fallback is restored. A deterministic two-thread probe reproduces the remaining race and leaves the database row with api_content = NULL. Moving sidecar stamping under the same persistence lock makes the same probe pass.
Overall assessment
REQUEST_CHANGES. The core design and the latest fail-closed wrapper fix are correct, but the original early-persistence problem still has a real concurrency window. Sidecar stamping and the immediately following turn-start persistence should share the same critical section with shutdown persistence, or the code should recheck _row_id after acquiring the lock and perform the exact backfill. Add the deterministic concurrency regression before merge.
English verdict: REQUEST_CHANGES on exact head bd844e0ad5ac8eb20e6b4bd4c15f7764c129703a. The row-addressed design and latest fail-closed wrapper fix are correct, and 65 focused tests plus all current CI checks pass. A deterministic close-flush interleaving still leaves the durable row with api_content = NULL because sidecar stamping reads _row_id outside the persistence lock. Serialize stamping with close persistence, or recheck and backfill after acquiring the lock, then add the concurrency regression.
| # | ||
| # Rotation mode needs nothing here: its compacted copies flush to | ||
| # the child session after this stamp. | ||
| _row_id = _turn_user_msg.get("_row_id") |
There was a problem hiding this comment.
_row_id can appear immediately after this read. Close-time flush holds _session_persist_lock, but this stamp runs before _persist_turn_start() acquires it. I paused the close flush after _db_flush_collect() copied a row with no sidecar and before sync_flushed_message_markers(). This branch saw no row ID and returned. The close flush then committed api_content = NULL, added _row_id, and the later turn-start persist skipped the marked message. The next resume still replays different bytes. Please serialize the stamp and following persist with close persistence, or re-check and backfill after taking the lock. Putting the stamp under the same RLock made the deterministic probe pass.
There was a problem hiding this comment.
Addressed in #105842 (commit b8515b539f): the _row_id read and the row-addressed UPDATE now run under _session_persist_lock (an RLock; nothing on the prologue path holds it at that point, _persist_turn_start acquires it separately afterwards), re-checking _row_id after acquiring — so either the stamp runs first and the close flush waits, or the flush finished and the id is visible. Same shape as @salch-cred's 94e0ba2 on #103721, co-credited there. Thanks for the interleaving trace.
…istence @ehz0ah's review on the sibling PR NousResearch#102411 (same NousResearch#102194 bug family) found a P1 race that also applies here: _stamp_api_content_sidecar read _row_id before _persist_turn_start() acquired _session_persist_lock. A close/early flush holding that lock can copy this row out (no sidecar), not yet have written _row_id back onto the live dict, and the sidecar stamp -- running outside the lock -- sees no row id and skips the backfill entirely. The close flush then finishes committing api_content = NULL and marks the message persisted, so turn-start persist skips it too, permanently losing the correction. Move the _row_id read (and the DB backfill) into a closure executed under agent._session_persist_lock, re-checking _row_id AFTER acquiring the lock rather than before, so this stamp and a close flush can never interleave: either the close flush blocks until we finish, or it has already finished and stamped _row_id by the time we acquire the lock.
|
Thanks @JoaoMarcos44 — landing this via #105842: your row-addressed backfill — cherry-picked with your authorship intact (commit Closing this one so there is a single carrier for the issue; your credit is in the salvage PR body and in git history. |
… turns (NousResearch#102194) The api_content sidecar ('persist what you send') preserves prompt-cache stability across turn boundaries by persisting the exact API-bound bytes (including memory-manager prefetch, plugin injections, and API-only notes) and substituting them on replay. When a user turn was already materialized in the database before the sidecar could be composed (in-place preflight compaction or a close/early flush racing the prologue on the CLI path), the turn-start crash persist marker-skips that message. Previously, the backfill was gated strictly on in-place compaction (_preflight_compressed and _last_compaction_in_place), so racing CLI flushes left api_content = NULL in SQLite and broke prompt caching on subsequent turns (NousResearch#102194). Positional approaches (such as NousResearch#102239 and NousResearch#102286) using LIMIT 1 on the newest active user row are unsafe: repeated common inputs ('ok', 'yes', 'continue') cause the backfill to match and overwrite the PREVIOUS turn's row with the new turn's sidecar, corrupting history and breaking cache parity. Resolve all landing blockers and review feedback from NousResearch#102411: 1. Bounded state owner (Sahilvishnaliya): Add SessionDB.set_message_api_content(session_id, row_id, content, api_content) to SessionMessagesMixin in hermes_state_messages.py instead of growing hermes_state.py. Update set_latest_user_api_content docstring with durable warning on the positional hazard. 2. API-only turns & durable content selection (ehz0ah): When a pre-flushed clean input has an API-only difference (e.g. voice prefix or model-switch note): - Retain the differing API-facing bytes as api_content even when no new memory or plugin context was injected. - Derive the durable content guard using _override_replaces_content so the SQL 'content IS ?' guard matches the clean override text stored in the DB row rather than the restored wire text. 3. Turn prologue gating (_row_id) & fail-closed store duck-typing (ehz0ah): In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on the live user dict (stamped by _insert_message_rows and synced by sync_flushed_message_markers). If valid (positive int, not bool), address by exact ID. Do NOT fall back to positional matching when a row ID is present: if an external or custom wrapper lacks set_message_api_content, fail closed and skip rather than corrupting a neighbouring row. If absent but in-place compacted, fall back to positional update. On normal turns, skip the backfill entirely (single atomic INSERT). 4. Real lifecycle test coverage (salch-cred, ehz0ah): Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py covering store guards, surrogate scrubbing, gate non-arming, older identical row protection, real close-flush row_id synchronization, API-only clean override preservation with exact wire replay, and duck-typed store fail-closed verification when set_message_api_content is absent. Fixes NousResearch#102194. Closes NousResearch#102411.
_stamp_api_content_sidecar read _row_id without holding _session_persist_lock. A close/early flush holds that lock while it commits the row and only afterwards writes _row_id back onto the live dict; a stamp that ran in between saw no id and skipped the backfill, the flush finished with api_content = NULL and marked the message persisted, and the turn-start persist skipped it — the row kept the wrong bytes with no writer left to fix it. Run the _row_id read and the DB backfill under the (re-entrant) lock, re-checking _row_id after acquiring it. Race reported by @ehz0ah on Co-authored-by: sal <141555468+salch-cred@users.noreply.github.com> NousResearch#102411; same fix shape as @salch-cred's follow-up on NousResearch#103721.
… turns (#102194) The api_content sidecar ('persist what you send') preserves prompt-cache stability across turn boundaries by persisting the exact API-bound bytes (including memory-manager prefetch, plugin injections, and API-only notes) and substituting them on replay. When a user turn was already materialized in the database before the sidecar could be composed (in-place preflight compaction or a close/early flush racing the prologue on the CLI path), the turn-start crash persist marker-skips that message. Previously, the backfill was gated strictly on in-place compaction (_preflight_compressed and _last_compaction_in_place), so racing CLI flushes left api_content = NULL in SQLite and broke prompt caching on subsequent turns (#102194). Positional approaches (such as #102239 and #102286) using LIMIT 1 on the newest active user row are unsafe: repeated common inputs ('ok', 'yes', 'continue') cause the backfill to match and overwrite the PREVIOUS turn's row with the new turn's sidecar, corrupting history and breaking cache parity. Resolve all landing blockers and review feedback from #102411: 1. Bounded state owner (Sahilvishnaliya): Add SessionDB.set_message_api_content(session_id, row_id, content, api_content) to SessionMessagesMixin in hermes_state_messages.py instead of growing hermes_state.py. Update set_latest_user_api_content docstring with durable warning on the positional hazard. 2. API-only turns & durable content selection (ehz0ah): When a pre-flushed clean input has an API-only difference (e.g. voice prefix or model-switch note): - Retain the differing API-facing bytes as api_content even when no new memory or plugin context was injected. - Derive the durable content guard using _override_replaces_content so the SQL 'content IS ?' guard matches the clean override text stored in the DB row rather than the restored wire text. 3. Turn prologue gating (_row_id) & fail-closed store duck-typing (ehz0ah): In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on the live user dict (stamped by _insert_message_rows and synced by sync_flushed_message_markers). If valid (positive int, not bool), address by exact ID. Do NOT fall back to positional matching when a row ID is present: if an external or custom wrapper lacks set_message_api_content, fail closed and skip rather than corrupting a neighbouring row. If absent but in-place compacted, fall back to positional update. On normal turns, skip the backfill entirely (single atomic INSERT). 4. Real lifecycle test coverage (salch-cred, ehz0ah): Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py covering store guards, surrogate scrubbing, gate non-arming, older identical row protection, real close-flush row_id synchronization, API-only clean override preservation with exact wire replay, and duck-typed store fail-closed verification when set_message_api_content is absent. Fixes #102194. Closes #102411.
_stamp_api_content_sidecar read _row_id without holding _session_persist_lock. A close/early flush holds that lock while it commits the row and only afterwards writes _row_id back onto the live dict; a stamp that ran in between saw no id and skipped the backfill, the flush finished with api_content = NULL and marked the message persisted, and the turn-start persist skipped it — the row kept the wrong bytes with no writer left to fix it. Run the _row_id read and the DB backfill under the (re-entrant) lock, re-checking _row_id after acquiring it. Race reported by @ehz0ah on Co-authored-by: sal <141555468+salch-cred@users.noreply.github.com> #102411; same fix shape as @salch-cred's follow-up on #103721.
* fix: keep Bot Mode pet selection rings inside the gallery
* fix(prompt): keep memory guidance within available tools
* fix(compression): keep lean tails lean after auxiliary feasibility
Lowering the session trigger must not replace the window-relative lean
selection budget with threshold times target_ratio. Invalidate the lean
cache through the existing property while preserving explicit legacy and
external-engine fallback behavior.
Narrow adaptation of the aux-sync diagnosis and invariants in #93576,
without adding a required recalibration method to context engines.
Related: #95681, #93576
Co-authored-by: Turgut Kural <58116817+TurgutKural@users.noreply.github.com>
* feat(desktop): let users order Group Chat rooms
Add Move up/down controls for actual rooms without changing bot or folder
ordering. Preserve default pin/activity ordering until an explicit move,
retain hidden room slots, and persist Desktop-local order through room
updates, mirror merges, and hydration. No membership or routing writes.
Adapted narrowly from the group ordering idea in archived
NousResearch/Hermes-Bot-Mode#105 by @onuraycicek; rename already exists.
Co-authored-by: Onur Aycicek <onur.m.aycicek@gmail.com>
* fix: hide inactive grouping options from delegation schema
* fix(desktop): show the focused bot's working think pose
Port Adolanium's focused-turn pose from Hermes-Bot-Mode#101 and
hermes-agent#88134 to the current typed Bot Mode implementation.
Match the busy signal's connection-qualified focused owner rather than
the gateway socket, retain worker activity, and ease transitions in
elapsed time on the existing shared face clock.
Includes owner-isolation and animated-pose invariants, both proven red
on origin/main, and native Electron before/after verification against
a real temporary Hermes backend with held loopback inference.
Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
* fix(desktop): load property card guidance only on demand
* fix(cli): keep monitor repaints safe during prompt handoff
* fmt(js): `npm run fix` on merge (#106039)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fmt(js): `npm run fix` on merge (#106065)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* test(desktop): retire model catalog fixture jobs at teardown
* fix(desktop): let subagent header collapse roster and details
* feat: add GPT Image 2.5 generation and editing to OpenAI provider
* feat: add FAL GPT Image 2.5 generation and editing selections
* chore(deps): bump httpx2 in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [httpx2](https://github.com/pydantic/httpx2).
Updates `httpx2` from 2.7.0 to 2.12.0
- [Release notes](https://github.com/pydantic/httpx2/releases)
- [Changelog](https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md)
- [Commits](https://github.com/pydantic/httpx2/compare/v2.7.0...v2.12.0)
---
updated-dependencies:
- dependency-name: httpx2
dependency-version: 2.12.0
dependency-type: direct:production
dependency-group: uv
...
Signed-off-by: dependabot[bot] <support@github.com>
* feat(desktop): default glass to 29% tint on the sidebar
* docs(desktop): document sidebar glass defaults
* fix(desktop): keep background reports behind bounded disclosures
* docs(desktop): explain background report disclosures
* fix: trim computer use tool schema guidance
* fix(desktop): pin Windows update handoff cwd
* test(desktop): exercise production cwd setup in Windows self-test
* fix(mcp-oauth): keep refresh_token when a refresh response omits it (#62333)
HermesProviderMixin._handle_refresh_response overrides the SDK's handler (to
accept any 2xx and keep token bodies out of logs) but dropped the SDK's RFC 6749
section 6 carry-forward. An authorization server that does not rotate refresh
tokens (TinyFish, Google, Zoho, Asana, Futu) answers the refresh grant without a
refresh_token; we then stored the response verbatim, erasing the only refresh
token we had, so the next expiry had nothing to refresh with and forced a
browser re-auth roughly one TTL after every login.
Carry the prior refresh_token (and scope, per section 5.1) forward on the
OAuthToken before _store_tokens, so both the live provider and the on-disk
token file keep it. A rotating AS still wins: only None fields are filled.
Tests: two invariants on the real HermesMCPOAuthProvider + HermesTokenStorage
(omitted -> preserved in memory and on disk; provided -> rotated). The
carry-forward test is red on main.
* fix(desktop): allow project creation while browsing all profiles
* test(desktop): cover project creation scope and reconnect routing
* fix(observability): attribute ACP and batch execution surfaces
Fleet telemetry showed "unknown" as the single largest execution_surface
bucket. Two construction paths were mis-attributed, both silently:
1. ACP editor sessions (VS Code / Zed / JetBrains) declare platform="acp",
but "acp" was absent from EXECUTION_SURFACES, so the contract's
closed-schema fallback folded every editor session into "other" --
the bucket meant for genuinely unclassifiable traffic.
2. batch_runner built agents from _AGENT_PASSTHROUGH, which omitted
"platform" entirely, so every batch task run reported "unknown"
despite "batch" already being a first-class surface.
Neither is a reporting bug in the exporter: both are declaration gaps at
the construction site. "unknown" must mean "this run genuinely could not
be attributed", not "a construction site forgot to say who it was".
Changes:
- add "acp" to EXECUTION_SURFACES and map it to the "interactive"
entrypoint alongside cli/desktop/tui
- add "acp" to the v2 wire schema enum (kept in sync by an existing test)
- pass platform through batch_runner: added to _AGENT_PASSTHROUGH, set
self.platform = "batch" on the runner, and defaulted at the worker call
site so callers that build a config without it stay attributable
Wire compatibility: the ingest service validates the envelope only and
stores metric bodies verbatim, so packages carrying the new value are
accepted by the already-deployed server. No coordinated deploy needed.
Tests: 12 new behavioural tests. Verified red before the fix (4 failed),
green after. Three fix-mutants confirmed killed:
M1 revert acp from EXECUTION_SURFACES -> 3 failed
M2 revert acp entrypoint mapping only -> 1 failed
M3 revert batch passthrough -> 1 failed
No source-text assertions; every test is a contract between the surfaces
the schema accepts and the surface each path declares. A guard test pins
that a genuinely undeclared run still reports "unknown", so attribution
cannot be "fixed" by inventing a default that hides real gaps.
* fix(desktop): keep visible renderer animations running on blur
* fix(desktop): keep pets and starmap animated without focus
* fix(desktop): keep macOS HUD visible when inactive (#102573)
* fmt(js): `npm run fix` on merge (#106231)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* feat(desktop): show messages below the thread viewport
* test(desktop): cover scrolling message counts and pane isolation
* fmt(js): `npm run fix` on merge (#106237)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix(agent): a surface switch must not re-prefill the whole request (#104414)
`_stored_prompt_matches_runtime` treated `Platform` as a runtime-identity field, so
answering a live session from another surface — desktop -> TUI, or a resume after a
dashboard restart whose chat is a PTY TUI child — declared the stored prompt stale and
rebuilt it. The system prompt is the first thing in the request, so changing any byte of
it moves the first divergent byte to the head of a 220K-token request and the entire
conversation behind it re-prefills: a session that was hitting 240000/240287 came back
at 1536/219861.
The guard was not wrong about correctness — a desktop-built prompt on a terminal session
advertises inline widgets and a MEDIA: channel the TUI does not have — but the surface is
advisory metadata about the renderer, not a cache domain. Model/provider and cwd drift
change what the prompt should SAY; the surface changes only one paragraph.
Reuse the stored bytes across a surface switch and correct the paragraph where it costs
nothing to cache: `_stage_surface_switch_note` stages a one-shot note carrying the CURRENT
surface's guidance on the same per-turn user-message channel the gateway's must-deliver
notes use. It lands after the cached prefix and is stamped into the byte-stable
`api_content` sidecar, so later turns replay it instead of re-prefilling, and the prompt
converges at the next compaction — a boundary that already breaks the cache.
The saved tool_names prefix is not pinned across a switch: the tool registry is
process-global, so `_merge_preserving_prefix` would carry a saved-but-unloaded tool
forward (under `coding_context: focus` desktop gets a desktop_ui toolset the TUI cannot
run). On the same surface the tools freeze is untouched.
* fix(agent): skip the tools freeze once on a surface switch, not for the session
The first cut gated the saved tool_names pin on "the surface drifted", which stays true
for as long as the stored prompt names the old surface — i.e. until the next compaction.
On the gateway path, where a fresh AIAgent is built per turn, that left the tools freeze
off for every remaining turn, so a check_fn that flaps could reorder `tools[]` and break
the tool cache block on its own.
Gate it on the turn that actually ANNOUNCES the switch instead, and persist the fresh,
toolset-correct names there. The next turn's row already holds this surface's tools, so
the pin resumes immediately: skipped once, not disabled.
* fix(agent): the surface note must not outlive its own truth
Two holes the first cut left open, both created by the note itself.
Switching BACK to the surface the prompt was built for (desktop -> tui -> desktop) left the
`Platform:` trailer agreeing with the runtime, so nothing was staged — while the newest note
in the transcript still told the model it was on tui. And a rebuild for an unrelated reason
(a model switch) refreshed the prompt but not that note, leaving the same contradiction from
the other side.
Compare the runtime surface against what the model was last TOLD — the newest surface note
when one exists, else the prompt's own trailer — and stage from the rebuild path too. The
full surface guidance rides along only when the prompt itself is out of date; when the prompt
already describes the current surface the note just retires the stale one and points at it.
* fix(agent): hold the tools pin through a surface switch, name what it carried
The announcing turn used to skip the tools freeze and re-persist the array the new
surface had just built. That is the one mutation this fix cannot afford: tools[] is
serialized ahead of the system prompt, so rebuilding it moves the request at token 0
and re-prefills everything behind it — the exact cost #104414 measured (1% cache hit
on a 220K session), spent on the very turn the fix exists to make cheap. On a
`desktop -> tui` switch with a configured toolset selection (`_gui_surface_toolsets`
gives desktop `desktop_ui`, the TUI nothing), skipping the pin dropped ~a dozen tools
and bought back the whole miss.
The pin now holds. `_merge_preserving_prefix` still appends what the new surface
brought, so a `tui -> desktop` switch pays a break no freeze could have avoided, and
the tools it carries FORWARD are named at the end of the surface note instead of being
silently advertised: a `focus_pane` a terminal turn can only answer with
`tool_error("desktop only")` now reads as unavailable rather than as live capability.
The toolset converges at the next real rebuild boundary, where the break is already
paid.
Credit to @StanleyStetson, who caught that the tool array is evaluated ahead of the
system prompt and that the bypass reintroduced the miss this PR is about.
* fix(agent): retire stale surface notes on bot-chat refresh, isolate platform from decoys
When Bot Chat capability refresh rebuilds the system prompt for the current
surface, call _stage_surface_switch_note() so any earlier switch note sitting in
the transcript is retired instead of overriding the rebuilt prompt.
Also isolate _stored_prompt_platform() to parse only the authoritative identity
portion before '# Hermes runtime environment' (with legacy fallback for prompts
without the boundary), preventing embedder prose or HERMES_ENVIRONMENT_HINT decoys
from shadowing the real platform and falsely suppressing surface switch announcements.
Credit to @ehz0ah, who identified both correctness gaps on current main and
verified the regression scenarios.
* refactor(agent): surface-switch note lives in its own sibling; skip it where no sidecar exists
Move the six surface-switch helpers out of the conversation_loop facade
into agent/surface_switch.py (AGENTS.md: new behaviour goes in a topical
sibling), and fold the review findings on #104494:
- MoA and codex_app_server turns never stamp the api_content sidecar, so
the staged note could not be read back from the transcript and was
re-sent on every turn after a switch. Those modes now skip the note
(stored prompt still reused).
- The announced surface was parsed with split(".") — a plugin platform
with a dot in its name would never compare equal and re-stage the note
every turn. The note now closes the name with a fixed terminator.
- One identity-line parser (identity_line_value) shared by
_stored_prompt_matches_runtime and the switch detector instead of two
copies of the runtime-boundary/rpartition logic; tool names via the
existing tools.mcp_tool_agent._def_name; the transcript scan is bounded
to the last 200 rows (it ran every turn over the whole history).
- consume_surface_switch_note reduced to a plain pop; developer-guide
prompt-assembly.md updated (Platform is no longer an identity field);
17 new tests trimmed to 10 (same-shape pin/retire variants folded).
Restoring Platform as an identity field still turns 5 tests red.
* simplify(agent): surface switch — reuse flatten_message_text / agent_tool_names / one runtime-boundary split
- _transcript_row_texts re-implemented agent.message_content.flatten_message_text
and the api_content sidecar rule; the note can only land on a user row,
so the transcript scan now skips assistant/tool rows (the bulk of the bytes).
- Three sites computed "names of agent.tools"; tools.mcp_tool_agent gains
agent_tool_names() used by the switch note and conversation_loop, which
also stops importing the private _def_name across modules. The name list
is only captured when a switch was announced.
- split_runtime_boundary() is the single owner of the runtime-block
rpartition/END check for both identity_line_value and
_stored_prompt_matches_runtime.
- platform_surface_hint was a public alias of _platform_hint; the function is
now platform_hint (its docstring pointed at the pre-move module).
- consume_gateway_turn_context_notes and consume_surface_switch_note share
_pop_turn_note so the two one-shot channels have identical semantics.
- platform check hoisted above the transcript scan.
* fix(agent): row-addressed api_content backfill for pre-persisted user turns (#102194)
The api_content sidecar ('persist what you send') preserves prompt-cache
stability across turn boundaries by persisting the exact API-bound bytes
(including memory-manager prefetch, plugin injections, and API-only notes)
and substituting them on replay.
When a user turn was already materialized in the database before the
sidecar could be composed (in-place preflight compaction or a close/early
flush racing the prologue on the CLI path), the turn-start crash persist
marker-skips that message. Previously, the backfill was gated strictly on
in-place compaction (_preflight_compressed and _last_compaction_in_place),
so racing CLI flushes left api_content = NULL in SQLite and broke prompt
caching on subsequent turns (#102194).
Positional approaches (such as #102239 and #102286) using LIMIT 1 on the
newest active user row are unsafe: repeated common inputs ('ok', 'yes',
'continue') cause the backfill to match and overwrite the PREVIOUS turn's
row with the new turn's sidecar, corrupting history and breaking cache parity.
Resolve all landing blockers and review feedback from #102411:
1. Bounded state owner (Sahilvishnaliya):
Add SessionDB.set_message_api_content(session_id, row_id, content, api_content)
to SessionMessagesMixin in hermes_state_messages.py instead of growing
hermes_state.py. Update set_latest_user_api_content docstring with durable
warning on the positional hazard.
2. API-only turns & durable content selection (ehz0ah):
When a pre-flushed clean input has an API-only difference (e.g. voice
prefix or model-switch note):
- Retain the differing API-facing bytes as api_content even when no
new memory or plugin context was injected.
- Derive the durable content guard using _override_replaces_content so
the SQL 'content IS ?' guard matches the clean override text stored
in the DB row rather than the restored wire text.
3. Turn prologue gating (_row_id) & fail-closed store duck-typing (ehz0ah):
In agent/turn_context.py::_stamp_api_content_sidecar: check _row_id on
the live user dict (stamped by _insert_message_rows and synced by
sync_flushed_message_markers). If valid (positive int, not bool), address
by exact ID. Do NOT fall back to positional matching when a row ID is
present: if an external or custom wrapper lacks set_message_api_content,
fail closed and skip rather than corrupting a neighbouring row. If absent
but in-place compacted, fall back to positional update. On normal turns,
skip the backfill entirely (single atomic INSERT).
4. Real lifecycle test coverage (salch-cred, ehz0ah):
Comprehensive tests in tests/agent/test_api_content_row_addressed_backfill.py
covering store guards, surrogate scrubbing, gate non-arming, older identical
row protection, real close-flush row_id synchronization, API-only clean
override preservation with exact wire replay, and duck-typed store fail-closed
verification when set_message_api_content is absent.
Fixes #102194.
Closes #102411.
* fix(agent): read the sidecar row id under the session persist lock
_stamp_api_content_sidecar read _row_id without holding
_session_persist_lock. A close/early flush holds that lock while it
commits the row and only afterwards writes _row_id back onto the live
dict; a stamp that ran in between saw no id and skipped the backfill,
the flush finished with api_content = NULL and marked the message
persisted, and the turn-start persist skipped it — the row kept the
wrong bytes with no writer left to fix it.
Run the _row_id read and the DB backfill under the (re-entrant) lock,
re-checking _row_id after acquiring it. Race reported by @ehz0ah on
Co-authored-by: sal <141555468+salch-cred@users.noreply.github.com>
#102411; same fix shape as @salch-cred's follow-up on #103721.
* refactor(agent): one durable-row rule for the flush and the sidecar stamp; trim tests
The turn-start stamp had grown its own copy of the "what does the current
user row hold" rule (persist override = clean transcript, live content =
wire bytes = sidecar when they differ) that _db_flush_row already
implements. Two copies drift; extract durable_user_row_content() in
session_persistence and call it from both.
Also: reuse _persist_lock() instead of a third open-coded lock/nullcontext
ladder; drop the hasattr guard on set_latest_user_api_content (it predates
this fix and exists on every SessionDB); cut the comment to the WHY;
trim the new test file from 18 cases to the 7 invariants (real close
flush E2E, repeated-"ok" positional protection, API-only pre-flushed
turn, normal path writes nothing, compaction keeps positional, store
guards). Still 3 red / 4 green when agent/turn_context.py is swapped
for main's copy.
* simplify(agent): sidecar backfill — drop the hasattr guard and the duplicated row-id predicate; tests 7→6
_session_db is always a SessionDB (agent_init / delegate_tool), so the
"fail closed on a store wrapper" hasattr was defense around code that
cannot fail; the store's own guard binds the value into SQL, so the
prologue only needs the sibling idiom isinstance(_row_id, int) that
session_persistence and transcript_repair already use. The positional
hazard is explained once, on set_latest_user_api_content. The in-place
compaction test duplicated test_api_content_sidecar's
test_inplace_compaction_backfills_sidecar_into_db verbatim (its row_id
parameter was never varied); dropped, as was the positional-helper tail
of test_older_identical_row_is_untouched already covered there.
* chore: map contributor email for @0xalydev (#103581 salvage)
* fix(agent): inherit parent's full tool surface on review fork for cache parity (#103579)
Ensure unrouted background_review forks inherit the parent's full advertised
tools[] surface. Without this, skip_memory=True caused memory-provider tools
(e.g. fact_store/fact_feedback) and dynamically injected plugin/late MCP tools
to be omitted from the fork's tools array, breaking byte-exact prefix-cache parity
and incurring full cold-read costs on providers where tools are part of the cache key.
Inheriting the full parent tools array preserves complete prefix cache parity
while execution dispatch remains strictly bounded by the thread tool whitelist.
* fix(background-review): freeze review fork tool snapshot generation against compaction refresh
Freezes review_agent._tool_snapshot_generation to _FROZEN_TOOL_SNAPSHOT_GENERATION
(2_147_483_647) when inheriting the parent tool surface for same-model cache parity.
When in-place compaction boundaries trigger refresh_agent_mcp_tools(content_aware=True),
the staleness guard in _publish_tool_snapshot refuses the rebuild (snapshot_generation < published_gen),
preventing agent.tools from being reconstructed from the raw registry and preserving
inherited memory-provider and late tools across compaction boundaries (#103579).
Adds unit regression test verifying tool preservation across content_aware refresh.
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
* fix(background-review): inherit and freeze empty parent tools list for cache parity (#103579)
Copy and freeze review_agent._tool_snapshot_generation even when parent.tools is an empty list ([]). Previously, the truthiness check (\
ot parent_tools\) caused an empty parent tool surface to be skipped, allowing newly available late MCP or plugin tools to be retained on the review fork and leaving its snapshot generation unfrozen. This broke the byte-parity contract when no tools were active on the parent.
Returning early only when parent_tools is not an instance of list or tuple guarantees that an empty tool snapshot is faithfully inherited and frozen. Adds dedicated regression test test_unrouted_review_fork_inherits_empty_tool_surface.
* refactor(background-review): collapse the tool-surface copy to the agent_init shape; 2 tests
agent.tools is always a list (agent_init assigns it from
get_tool_definitions) and every entry is a well-formed function schema,
so the isinstance ladder over parent/entry/function/name guarded shapes
that cannot reach this helper. Use the same two lines agent_init uses;
`or []` keeps the empty-surface contract from the previous commit.
Docstring cut to the WHY (the between-turn refresh note described the
other guard). Tests trimmed to the two invariants: inherited tools
survive the compaction-boundary refresh (deep-copy isolation folded in),
and an empty parent surface is copied and frozen. Literal sentinel
asserts replaced with the constant.
* test(background-review): assert the behaviour, not the sentinel
The compaction-refresh and empty-surface tests pinned
_tool_snapshot_generation == _FROZEN_TOOL_SNAPSHOT_GENERATION next to
the behavioural assertion (refresh returns set(), tools unchanged). The
behaviour is the contract; the constant is the mechanism.
* chore(contributors): map sgarrand@gmail.com -> sgarrand
Scott Garrand (@sgarrand) identified the NixOS /bin/true systemd-probe bug
first in #102587; the salvage of #105436 credits him with a Co-authored-by
trailer, so the release script needs his mapping.
* fix(process-registry): use portable /bin/sh probe for systemd-run scope availability (#105365)
* test(process-registry): mark systemd probe tests linux_only
* test(process-registry): exercise the portable probe payload
Execute the selected no-op rather than freeze its spelling, while rejecting
/bin/true to model the NixOS failure. Mark the regression Linux-only and
retain the current user-bus environment handling.
Consolidates the earlier NixOS scope-probe report and fix in #102587 with
the PATH-independent payload from #105436. The fallback resolver is not
needed when /bin/sh is used directly.
Co-authored-by: Scott Garrand <sgarrand@gmail.com>
* fix(gateway): guard display config reads against present-but-null values
A profile config with a bare `display:` key (present-but-null) made
`user_config.get("display", {})` return None — the {} default only
applies when the key is missing — so the chained
`.get("memory_notifications")` in _wire_turn_agent_callbacks raised
AttributeError on every real gateway turn (Discord / cron). Oneshot
turns bypass this wiring, which masked the crash during smoke tests.
Use the same `or {}` guard the other gateway display readers
(display_config.py, runtime_footer.py) already apply, and fall back to
the documented default "on".
Fixes #105674
* test(gateway): fold the null/missing display cases into one parametrized test
* chore: map philmossman's contributor email (#105704 salvage)
* fix(cron): don't stamp the next occurrence on an off-tick manual run
claim_job_for_fire() derives the occurrence identity from next_run_at
before the same function advances it. On a scheduler tick next_run_at is
the occurrence being run, which is correct; on an off-tick manual run it
is the NEXT occurrence, so the execution is stamped with the identity of
a slot that has not happened yet. _job_is_due() then finds a completed
execution carrying that identity and skips the real slot, returning
before the last_dispatch write — no error, no log line, no dispatch
record.
The manual flag already guards this and both _job_is_due() and
claim_job_for_fire() honour it; the agent-facing run-now path never
declared itself. Add a keyword-only manual= parameter and pass it from
_claim_for_manual_run(). Deliberately not force=True: force also calls
_activate_job_record(), which would resume a paused or disabled job, and
the run-now tool depends on continuing to refuse those.
The local flag is renamed to manual_fire so the new parameter is not
shadowed inside the apply closure, which would raise UnboundLocalError.
Three existing tests in tests/tools/ pinned the old call signature via
assert_called_once_with; they now pin manual=True, so dropping the flag
again fails loudly rather than silently reintroducing the skip.
Restores the intent stated in #104790 — the column records the scheduled
instant an execution was claimed for, and an off-tick manual run was
claimed for none.
Fixes #105690
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(cron): the dashboard "Trigger" run-now no longer stamps the next occurrence either
Second entry of the same bug class: POST /api/cron/jobs/{id}/trigger →
_fire_cron_job_for_profile → CronScheduler.fire_due → claim_fire built its claim
without `manual`, so an off-tick run from the web UI stamped the future slot exactly
like the tools path #105704 fixes. fire_due/claim_fire gain `manual` (forwarded only
when set, mirroring `force`, so third-party providers keep working) and the dashboard
trigger passes it when the provider's signature accepts it. Webhook and misfire
catch-up fires run the slot that is due and keep the stamp.
Also drops the base-green tick-stamp test (the same contract is pinned by
tests/cron/test_scheduled_occurrence.py) and documents `manual` vs `force`.
* chore: map tkaufmann's contributor email (#105463 salvage)
* fix(agent): classify local-inference memory-ceiling rejections as overloaded
oMLX/MLX prefill memory-guard rejections name an allocation peak in BYTES but
close with "Reduce context length", so _CONTEXT_OVERFLOW_PATTERNS claims them
and the turn enters the compress-and-shrink loop. Compression cannot lower a
prefill peak — the prompt is usually far below the window — so it burns the
compression budget, re-hits the wedged server on every attempt and ends in
"Cannot compress further" plus a destructive session reset.
Classify them as `overloaded` instead: retry with backoff, no compression, no
session reset (mirrors 503/529 recovery).
The guard runs before the overflow check AND before the usage-limit
disambiguation. The second ordering matters more than it looks: "memory limit
exceeded" contains "limit exceeded", so a status-less rejection — a proxy that
flattened the body — is currently classified as `billing` and rotates a
healthy credential.
Sites covered:
- _OVERFLOW_AS_5XX_RULES, which _400_TAIL_RULES extends → 400, 500, 502,
503, 529
- _MESSAGE_HEAD_RULES for the status-less path (ahead of usage-limit)
- _ERROR_CODE_VERDICTS for the structured oMLX codes
- _classify_400, because _by_status runs before _by_error_code, so a body
whose wording a proxy stripped would otherwise fall through to
format_error
Every pattern names memory/allocation in bytes, never a token or window count,
so the list stays disjoint from _CONTEXT_OVERFLOW_PATTERNS. Both oMLX wordings
are kept: 0.5.6 says "Prefill would require ~13.87 GB peak", 0.5.7 reworded it
to "predicted peak would require/exceed" and both are in the field. A test
pins that a genuine window overflow still compresses.
Refs #52261. Supersedes #52289, which predates the classifier rewrite and can
no longer be merged.
* test(agent): fold the five memory-ceiling cases into one parametrized invariant
Same coverage (5 red on main, 1 guard green), one contract test instead of five.
* fix(agent): isolate periodic scheduler callbacks from blocking siblings (#102574)
* refactor(agent): one _requeue for the three heappush sites; timing test asserts ordering, not a 0.3 s bound
Fold the identical heappush(...) into PeriodicScheduler._requeue; notify() instead of
notify_all() now that the scheduler thread is the only condition waiter; drop the
PR-history paragraph from the module docstring (the commit carries it).
Tests: the blocked-sibling test asserted `sibling_ran.wait(0.30)` — a wall-clock bound
under the repo's ≥2 s flake floor; it now asserts the sibling fired while the blocker
still held its worker. The worker-start-failure fake keys on this scheduler's own
_run_callback rather than the global thread-name prefix so a leaked handle on _DEFAULT
cannot consume the single injected failure. The base-green no-overlap test is dropped
(it does not prove the fix).
* fix(agent): scope background review memory access to its trigger (#105921)
The review fork's tool whitelist granted the whole memory toolset
whenever the profile had memory enabled, regardless of which nudge
fired, so a skill-nudge fork held remove/replace on MEMORY.md it was
never asked to use; combined with the memory tool's near-limit
'consolidate now' hint, an unattended fork deleted standing rules with
no user in the loop.
- Pass review_memory from spawn_background_review_thread through
_run_review_in_thread/_run_review_fork into _review_tool_whitelist;
a skill-only review no longer gets the memory tool at all.
- Fail-closed operation gate in memory_tool: a background-review fork
may add, never replace/remove (single or in a batch) — consolidation
decisions reach a human via the review summary instead.
- Keep the deny/prompt wording in sync with the whitelist so a
memory-less review doesn't advertise memory.
* fix(review): distinguish explicit /refine from unattended reviews and surface staged consolidations
Review follow-up on #105944 (#105921):
- explicit /refine forks now run under the refine_review write origin
(explicit flows from the CLI/gateway handlers through
_spawn_background_review_now and spawn_background_review_thread down
to build_cache_parity_fork), so a user-requested review keeps the
full memory operation set; only automatic reviews stay behind the
unattended delete gate.
- the unattended delete gate now stages the denied replace/remove (or
whole batch) into the pending store instead of dropping it: the
fork's own review summary is never published, so a plain denial lost
the consolidation request with no surfacing path. The staged proposal
carries a proposal_staged marker that summarize surfaces as an action
line, and a staging failure still fails closed to a plain denial.
- regression tests: explicit-path origin pass-through, refine_review
keeping replace working, near-limit denial end to end (add rejected
by budget -> replace staged -> proposal surfaces, store unchanged).
* fix(review): keep /refine under the background_review origin; attendedness is its own flag
The salvaged commit forked an explicit /refine under a new "refine_review" origin so
the memory delete gate would not treat it as unattended. But is_background_review()
is the key for every other review guard — skill_manager_guards (curator-owned-only,
read-before-write), skill_manager_tool (archive instead of rmtree), skill_ledger
actor, write_approval staging, the [auto] tag — so a /refine fork silently escaped
all of them.
Carry attendedness separately: the fork keeps origin "background_review" and sets
_review_attended; turn_context binds it beside the origin ContextVar; the memory
gate keys on the new is_unattended_review(). Also run the gate AFTER
_validate_single_op / the operations list check, as memory_tool's own docstring
requires, so an invalid replace is rejected now rather than staged and failed at
approve time.
* fix(sessions): serialize fresh FTS bootstrap
* fix(sessions): restore trigram after deferred bootstrap
* refactor(state): drop the table-exists probe made dead by the early return above it
* chore: map portavales's contributor email (#105694 salvage)
* fix(loop): re-anchor current_turn_user_idx after the alternation repair merges rows
prepare_iteration() runs repair_message_sequence_with_cursor() before each API
call; the repair merges adjacent user rows in place (after a compaction, the
role=user summary sits next to the protected first user message). The loop's
current_turn_user_idx was recorded at turn start, so after a merge it points
past the current user row: the per-turn context injection (prefetch/plugin
context) silently misses it, and hosts that settle the transcript by this index
(hermes-webui) write the current user turn to the FRONT of the context —
rewriting the prompt's leading messages every turn (0% prefix-cache hits at
200K+ tokens, ~100 s re-prefill per turn) and duplicating the user's question.
The in-loop compression restart path already re-anchors; do the same after a
repair that changed the list: reanchor_current_turn_user_idx (last user row
carrying this turn's text), return the index through the IterationPrep verdict
so the loop state picks it up, and mirror it into agent._persist_user_message_idx,
which hosts read when the result carries no index. The new phase parameters
default to None so direct callers keep their signature.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gp366ijf39n4UUtJhZuMh
* feat(loop): export {turn_id, current_turn_user_idx} on every result envelope
Hosts that settle their own transcript by index (hermes-webui) cannot prove which
row of result["messages"] is the current user turn once this loop rewrote history
(alternation repair, compaction, post-turn micro-compaction): the instance-side
_persist_user_message_idx predates those rewrites, and a text match relabels an
identical historical prompt and claims its old answer. Only the producer can
assert the coordinate against the exact list it returns.
run_conversation now wraps the turn (_run_conversation_turn) and stamps the pair
through export_current_turn_boundary on every envelope that leaves the loop
(success, partial/error, interrupt, retry-exhausted, tool-limit, preflight
timeout, codex runtime), computed on the final messages after finalize_turn and
micro-compaction. The pair is exported only when the addressed row is this turn's
user message verbatim (reanchor's last-match rule); a rewritten row exports
nothing so hosts fail closed. The final index is mirrored into
_persist_user_message_idx for the persist override.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gp366ijf39n4UUtJhZuMh
* test(agent): prove the re-anchor through prepare_iteration; reuse the compaction _reanchor
The salvaged regression test exercised only repair_message_sequence and
reanchor_current_turn_user_idx — pre-existing helpers — so reverting the fix left it
green. It now drives prepare_iteration on a real AIAgent with adjacent user rows and
asserts the returned index addresses this turn's row and mirrors into
_persist_user_message_idx (red without the re-anchor: IndexError).
Both re-anchor sites (repair and compression restart) call
turn_context_compaction._reanchor instead of inlining "reanchor + mirror", so they
cannot drift. The export tests fold into one parametrized invariant plus the
run_conversation envelope test; the WHAT-restating comment shrinks to the WHY.
* fix(gateway): preserve shared MCP visibility across profile reloads
* fix(gateway): register shared MCP tools per profile
* fix(mcp): a profile only adopts a shared connection whose credentials match its own config
_same_server_route compared config_fingerprint alone, which by design excludes
env/headers/auth (so the schema cache survives a token rotation). Profile B with the
same URL but different headers/env therefore adopted profile A's live connection and
called tools as A. _connection_identity = route fingerprint + env + headers + auth mode,
used by both the adopt and the stale-removal checks.
Also collapses the three writers of _server_tool_scopes to two: the adoption loop
re-implemented in mcp_tool_discovery._select_new_servers is dropped —
register_connected_into_current_scope (which runs first in register_mcp_servers) is
the single adopter, and _register_candidates records scope for freshly registered tools.
* fix(gateway): ledger-bracket the queued-lane final send
When a follow-up is queued behind a turn, the first response is delivered by
the queued lane before the follow-up runs. That lane called adapter.send bare
and discarded the result: no delivery-ledger obligation was recorded, so a
final refused there (flood control, a transport that had just died) was lost
for good. Neither the boot sweep nor the runtime redelivery could see it and
the follow-up ran as if the answer had landed.
Route the queued lane's text send through the adapter's _send_final_text, the
same ledger-bracketed method the normal lane uses: the obligation is recorded
before the send under the id the normal lane would compute for the same turn,
the result finalizes it, and the reply is marked notify-worthy like every
other final. The reconcile-by-edit path is unchanged; adapters without the
base contract and sends without a session key keep the plain send.
* fix(gateway): key the queued-lane obligation on the raw inbound id
The queued lane's ledger bracket used the reply anchor as the obligation's
message reference. The anchor is None wherever replies are not used (Telegram
forum topics, Slack reaction handoffs), so two turns in one topic answering
with the same text shared an obligation id and the second record overwrote
the first's outstanding row; the id also differed from the normal lane's.
The lane now runs the adapter's record / send-with-retry / finalize sequence
itself, keyed on turn_ctx.inbound_message_id like the normal lane, while the
anchor stays the reply target. Tests cover the forum-topic identity, the row
being `attempting` while the send is in flight, and the call site passing
both ids.
* fix(gateway): carry the raw inbound id through a chained queued turn
Round-2 review. _run_agent_deliver_first_response passed the turn's inbound id to
the queued lane, but the recursive _run_agent for a chained follow-up did not, so
the chained turn ran with inbound_message_id=None. In a Telegram forum topic (no
reply anchor) two chained follow-ups answering with the same text would then key
their queued-final obligations on None and collide. The recursive call now carries
pending_event's raw message id, with a test on the chained path.
* test(gateway): let the queued-native-image fake accept the persist kwargs
Now that a queued follow-up carries its raw inbound id, the gateway passes
persist_user_platform_id to run_conversation for that turn (run_turn_runner.py
only adds it when inbound_message_id is set). The real AIAgent accepts it; the
test's fake did not. Accept **kwargs, matching the real signature.
* fix(gateway): ledger a queued chain's terminal reply under its own inbound id
The outer final send is bracketed by the adapter against the event that OPENED
the chain, so a terminal reply was recorded under the first message's id. When
two turns of one chain answered with the same text, the terminal reply computed
the earlier row's obligation id, replaced its outstanding row and marked it
delivered, so a first reply the platform had refused was never redelivered.
MessageEvent gains a documented ledger_message_id that the obligation hash
prefers, the queued follow-up returns the terminal turn's inbound id (innermost
wins on nested chains), and the handler sets it before the adapter brackets the
send. Reply routing is untouched: the anchor still comes from the event.
Three of the four new tests fail without this change; the whole tests/gateway
suite shows the same failure set before and after.
* refactor(gateway): one send_final_ledgered bracket for the normal and queued final lanes
The queued lane re-implemented _send_final_text's record / send-with-retry / finalize
sequence by duck-typing four private adapter members from gateway/. Lift the bracket
into a public BasePlatformAdapter.send_final_ledgered(event, session_key, text,
metadata, *, reply_to, is_ephemeral_response); _send_final_text keeps only the
ephemeral-delete tail on top, the queued lane calls it with the inbound-id ledger event.
ledger_message_id is a real dataclass field now, read directly.
Tests trimmed from 18 to 8 invariants (bracket recorded+delivered; flood refusal stays a
failed ledger row; forum-topic identity; plain adapter keeps plain send; normal-lane
parity; chained/terminal/deeper-chain inbound ids). Still 6 red with main's
run_notifications.py swapped in.
* fix(state): guard close-time checkpoint for replaced/deleted-generation handles (#105670)
- close() and _try_wal_checkpoint() now skip when _db_replaced or _db_wal_generation_lost
(previously only _db_corrupt was checked) — prevents checkpointing stale-generation frames
into the main DB, which is the shutdown-time damage reported in #105670
- _halt_if_db_generation_changed() calls _disable_close_time_checkpoint() alongside the flag
set (3.12+: disables SQLite internal last-connection checkpoint too)
- Regression tests: halted handle must not run explicit PRAGMA checkpoint on close(),
halt must call setconfig(NO_CKPT_ON_CLOSE), periodic _try_wal_checkpoint() must skip
* refactor(state): drop the ruff-format reflow from the checkpoint guard, keep the ~20 semantic lines
The cherry-picked commit re-wrapped hermes_state.py wholesale (+527/-131 for a
fix of about twenty lines). Restore main's layout and re-apply only the fix:
disable the close-time checkpoint on both generation-loss halts, gate the
periodic checkpoint on the sticky generation flags, and name the quarantine
reason at close.
* test(state): mark the checkpoint-guard tests linux_only instead of a bare skipif
AGENTS.md: a bare skipif(sys.platform != linux) is never listed by
scripts/ci/list_os_marked_tests.py, so the tests would run nowhere on the
OS lanes. The marker is the contract.
* fix(state): the deferred FTS rebuild retry is quarantined by the same rule as the checkpoints
retry_deferred_fts_recovery gated only on _db_corrupt ("mirrors _try_wal_checkpoint /
close") — after this PR it no longer mirrored them: on a replaced/lost-generation handle
the periodic housekeeping tick still ran FTS DDL/DML + commit, the same split-brain write
class as the #105670 checkpoint. One SessionDB._quarantine_reason() now decides for the
periodic checkpoint, close(), and the FTS retry, with the halt path's precedence
(replaced before generation loss) and the operator wording in one place.
Test: the periodic-checkpoint case folds into the close test (same setup), which now
also proves the FTS retry returns False without touching the file; the mutation with
main's schema sibling swapped in returns True (a rebuild ran).
* chore: map albert748's contributor email (#104444 salvage)
* fix(agent): persist /steer as a standalone user message
`apply_pending_steer_to_tool_results` used to smear the steer text onto
the last `role:tool` message's content. That tool row had already been
flushed to the session store and carries `_DB_PERSISTED_MARKER`; the
append-only persistence never rewrites it, so the replayable transcript
diverged from the live request bytes at the injection point — resumed
sessions (surface switch / process restart / background-review close)
missed the provider prompt cache (75-85% hit) and the user's mid-run
instructions were never part of the durable history.
The steer is now emitted as a standalone `role:user` message (marker
text preserved):
- role alternation stays legal: assistant(tool_calls) -> tool -> user is
the documented 'user jumped in mid-run' pattern that
`repair_message_sequence` deliberately keeps;
- the appended dict carries no `_DB_PERSISTED_MARKER`, so the next
`_flush_messages_to_session_db` writes it to the session store —
transcript bytes and replayed history finally agree, and the steer
becomes searchable/retrievable like any other user message;
- the no-tool-result fallback (interrupt) still requeues the steer, which
the caller then delivers as a normal next-turn user message.
Tests: TestSteerInjection updated for the new shape plus a persistability
assertion (no marker => flushable); tool-batch-segmentation malformed
scenario updated. steer + segmentation suites: 67 passed, 1 skipped.
* test: keep the steer suite on the canonical patch targets, not PLUGIN-COMPAT pointers
The cherry-picked commit carried an unrelated hunk repointing three patch()
targets back to run_agent.* — those are PLUGIN-COMPAT re-exports, off limits
in-tree (scripts/check_compat_pointers.py; removed 2026-09-14). Keep main's
model_tools.* / agent.process_bootstrap.OpenAI targets.
* fix(agent): the pre-API-call /steer drain also stops smearing the persisted tool row
Second site of the same bug class #104444 fixes in apply_pending_steer_to_tool_results:
_inject_steer_into_newest_tool_result (the drain that runs when a /steer lands during an
API call) mutated the newest role:tool row in place. That row was already flushed
append-only, so the replayed history diverged from the live request bytes at the
injection point and broke the prompt cache exactly like the post-batch path.
Deliver it the same way: a standalone user row inserted right after the newest tool
result (not yet persisted, so the next flush writes it to the transcript). Restash when
there is no tool row yet, unchanged. Stale comments claiming steer lands "in the newest
tool result" and agent/AGENTS.md's alternation rule now describe the real shape.
* fix(agent): a persisted /steer row survives the next prompt's alternation repair; typed for history
Both steer sites now build the row through one helper, prompt_builder.steer_user_row:
a role:user row with display_kind="steer" and no leading blank lines. The alternation
repair (_merge_consecutive_users) skips a steer-typed prev row, so a run that ended
right after a steered batch (Ctrl-C, interrupt) does not get the next real prompt
merged INTO the already-persisted steer row — which would have rewritten it in place
and re-broken live≠replay parity, the exact class this PR fixes.
TUI/desktop history projects the steer row as the user's own words instead of the
model-facing marker wrapper; 'steer' joins the display_kind union. The compression
anchor scan keeps its tool-row branch for transcripts persisted before this change and
its docstring says so.
* fix(tui): resolve default profile session names
* fix(tui): preserve names for custom profile homes
* fix(tui): fail closed on unavailable profile targets matching custom root basenames
* test(tui): add coverage for custom default roots, real session db stamping, and sibling isolation
* fix(tui): a real named profile "hermes" is not swallowed by the legacy-basename alias
"hermes" matches the profile-id regex, so canonicalising it unconditionally at the RPC
boundary misrouted a genuine <root>/profiles/hermes to the default profile. Alias only
when no such named profile exists; ".hermes" can never be a real id and stays aliased.
Also: profile_name_for_home collapses its duplicated pre/post-resolve block into one
loop over (path, resolved path) and drops the bare "parent named profiles" fallback
that bypassed named_profile_home's root check; _profile_home goes back to main's
single resolve() comparison; the symlink-loop assertion in the target-unavailable
test is no longer wrapped in a try/except that could silently skip it.
* fix(profiles): a stored <root>/profiles/<name> home names its profile even when the root carries no markers
CI: tests/test_tui_gateway_server.py::test_ensure_session_db_row_stamps_profile_name used a bare tmp
root; profile_name_for_home fell through to None and the row was stamped default. The stored home
is authoritative (its owner resolved it), so the profiles/<name> shape is sufficient.
* fix(cli): honor --resume in one-shot mode (#105892)
The -z exit path accepted --resume/-c in the parser but never forwarded
args.resume: every resumed one-shot turn silently started a fresh session,
so each wire request carried only [system, current user] and the model
lost all prior context (reported against Ollama/custom OpenAI-compatible
endpoints, but provider-independent).
Normalize session args (latest/title/--continue/--in + cwd restore) via
the chat path's _resolve_chat_session_args before the oneshot exit path
takes over, then load the resumed transcript in _run_agent through the
same contract the interactive CLI uses (compression-chain redirect,
safe-resume guard, session_meta filtering) and continue the existing
session id instead of creating a new one. An explicit --resume of an
unknown session now fails loudly instead of starting fresh.
* fix(cli): keep the resolved session id when a resumed oneshot session is empty
Review finding on #105957: `_load_resume_target` returned None for a
resolved session with no stored messages, so `hermes -z "hello" -c <title>
--create-if-missing` recorded the turn under a freshly minted session id and
the just-created titled session stayed empty. Preserve `resolved` unconditionally — the interactive /resume path keeps the selected id for an
empty session too; only the history replay is empty. Regression tests pin the
durable id for both a plain empty session and an empty compression-chain head.
* fix(cli): restore stored session runtime and reopen ended rows on oneshot resume
Review fixes (#105957):
- A resumed one-shot ignored the session's stored model/provider runtime:
_resolve_model_and_provider()/resolve_runtime_provider() ran before
_load_resume_target(), which only loaded the session id + transcript, so an
ambient config (e.g. openrouter/ambient-model) served the resumed transcript
instead of the stored route (custom:stored/stored-model). The stored runtime
is now applied before runtime resolution, with the same contract as the
interactive _restore_session_model(): stored model/provider/base_url/api_mode
replace the ambient choice unless --model was passed explicitly, and a
changed provider drops the ambient api_key so resolution re-fetches
credentials for the restored endpoint.
- Passing the resumed id to AIAgent did not reopen the already-ended session
row: end_session() only writes rows whose ended_at is null and the
existing-row upsert never clears the end fields, so the resumed turn was
recorded under a session that stayed closed and its new lifecycle boundary
was lost. _load_resume_target() now reopens the row (best effort), same as
the interactive resume does before continuing.
* refactor(cli): one stored_session_route for interactive and one-shot resume
_apply_stored_session_runtime was a line-for-line copy of the first half of
_restore_session_model (stored-model guard, session_gateway_runtime, bare-custom heal,
model/provider-changed check). Extract that pure decision into
cli_model_switch_mixin.stored_session_route and have both resume paths call it; the
one-shot keeps only the _ModelChoice mapping and the drop-ambient-key rule.
main.py stops re-normalising `resume` — _resolve_chat_session_args already did.
Tests trimmed from 20 to 13: near-duplicate unit tests of the private helpers go, the
end-to-end _run_agent contracts (stored runtime + reopen; explicit --model wins) and the
empty-session-keeps-id case stay.
* fix(cli): keep the no-stored-model early return ahead of the route read
CI: tests/cli/test_cli_resume_command.py builds bare HermesCLI objects without .model; the
refactor read self.model before the stored-model check the contributor's code made first.
* test(agent): the worker-start-failure test intercepts the callback worker again
`kwargs.get("target") is sched._run_callback` is always False (a bound method is a fresh object
per access), so the fake never returned Boom and the _dispatch failure branch went untested;
the test passed on the normal worker. Compare with == and assert the interception happened
(mutation: retiring the handle on start failure now fails the test).
The thread-count assertions sampled while per-fire workers were still live; quiesce every
handle with cancel(wait=) before sampling so the count is deterministic (AGENTS.md: timing tests
must not assume a quiet runner).
Follow-up to #106308.
* fix(loop): the turn-boundary export skips preflight-timeout envelopes and stops re-anchoring the persist index
Follow-up to #106312. _preflight_timeout_result carries the prior history without this turn's
user row (#7100); with a repeated prompt ("continue") the verbatim scan resolved to the
historical copy and exported it as this turn's proven boundary — the exact relabeling the export
exists to prevent. Nothing is exported for that envelope now.
The trailing `agent._persist_user_message_idx = idx` ran after finalize_turn had already flushed
the transcript, so it never influenced a persist and the next turn reset it: dead state, removed.
* fix(gateway): the ephemeral delete goes to the adapter that sent the final
Follow-up to #106316. send_final_ledgered resolved the live adapter internally and
_send_final_text resolved it a second time for _schedule_ephemeral_delete; a reconnect between
the two sent the delete to a transport that never owned result.message_id (the ownership rule
_final_delivery_adapter documents). The bracket now returns (result, adapter).
The queued lane carried the ledger identity through MessageEvent.message_id while the PR added
ledger_message_id for exactly that; it now uses the typed field, and the ledger read is
getattr-tolerant of duck-typed events (a missing attribute was swallowed as "ledger skipped").
* fix(state): VACUUM is gated by the same quarantine rule as the checkpoints
Follow-up to #106315. vacuum() ran PRAGMA wal_checkpoint + VACUUM + wal_checkpoint(TRUNCATE) on
self._conn with no quarantine check; the only guard it inherited (optimize_fts raising
DeletedWalGenerationError) was swallowed by its own try/except and the rewrite proceeded on the
split-brain handle. Mutation on main: vacuum() returned 2 and rewrote pages after the write stop.
* fix(agent): a /steer row is human input for every user-turn predicate
Follow-up to #106317. Typing the steer row (display_kind="steer") for the renderer and the
alternation-repair guard collided with the convention that any display_kind on a user row means
scaffolding: is_user_originated_turn / _is_actionable_user_turn / split_user_originated_turn
returned False for it (tail anchoring, auto-focus, dispatcher views, resume counts) while
_is_real_user_message returned True (anchor restoration) — the two predicate families disagreed
on the same row, and list_recent_user_messages (/undo, /rewind) skipped it in SQL. A steer
carries full user authority; the steer kind is now whitelisted in all four.
Also: the pre-API drain's requeue tail reuses _requeue_pending_steer instead of a copy; the TUI
history projection compares against STEER_DISPLAY_KIND; the steer() docstring describes the row.
* fix(state): guard vacuum() and optimize_fts() against quarantined SessionDB handles
A quarantined/replaced/split-generation handle must never run a full-file rewrite or an FTS5
'optimize': both read damaged or foreign pages and commit the result back, turning contained,
diagnosable corruption into an amplified one. Same guard _execute_write applies to every write.
Salvaged from #102092 onto current main: the _try_wal_checkpoint half landed via #106315's
_quarantine_reason(), so only the two rewrite sites remain.
* fix(auth): preserve independent same-account OAuth grants
* fix(auth): carry pool-row lineage into the provider-block heal
With account-identity matching gone, the providers.<id> block consolidation
only fired on shared token material. A historical fork (same copied pool-row
id, profile rotated, both pairs diverged) then healed the pool row into root
but left root's providers.openai-codex block on the spent pair; root's next
load_pool() re-seeds its device_code row FROM that block and undid the heal.
_HealPass now records that a profile pool row matched root by copied id or
shared tokens and passes that verdict to _heal_forked_provider_block, which
accepts it as lineage proof. No account-identity guessing is restored; an
independent same-account grant (no id/token match) is still left alone.
Follow-up to simpolism's #106177.
* fix: address 6 P1 findings from merged PR review threads
- repair_controller.py: build the retirement completion command through
_governed_command_prefix() (adds -P) instead of a bare `python -m`
invocation, matching the sibling identity command; an untrusted
exact-head PR worktree could otherwise get prepended to sys.path.
- cli.py doctor probe: report worker_completion_policy failed whenever
HERMES_SAFE_MODE is active, since dispatched workers inherit it and
PluginManager skips all plugin discovery under it regardless of what
the profile config declares.
- worker_contract.py: default a manifest's missing `name` to its
directory name before comparing, matching parse_manifest_file()'s
actual runtime behavior, so a name-less override plugin.yaml is no
longer treated as absent.
- worker_contract.py: fail closed when a profile's plugins.enabled/
disabled list still contains an unexpanded ${VAR} reference, since
expanding it against doctor's own environment doesn't guarantee the
dispatched worker's .env resolves it the same way.
- methods_profiles.py: catch SystemExit (not just Exception) around
_write_raw_config_values(), which raises SystemExit for managed-scope
keys; the shared TUI/Desktop/dashboard RPC backend must not exit on a
refused profiles.configure write.
- config.py _preserve_env_ref_templates(): match a modified, reordered,
unnamed list entry to the loaded item it most structurally resembles
instead of the raw item at its new output position, so a sibling's
unchanged ${VAR} template isn't dropped into plaintext on save.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(tui-gateway): /review shows its reviewer in the Desktop subagent stack
`slash.exec` runs on the RPC pool, outside any turn, so `/review` dispatched the
reviewer with no HERMES_UI_SESSION_ID and no steer authority bound. delegate_task
registered the child with `owner_session_id=None`, `subagent.list` (owner-scoped)
returned nothing for the parent session, and the Desktop status stack's 5s
snapshot poll reconciled the live `subagent.start` row away — the user saw only
"Review started. Results will return here." with no subagent card.
Bind the same session identity a turn binds (`_set_session_context(...,
ui_session_id=sid)` + `_current_runtime_session_record`) around `start_review`,
and clear it after. The reviewer now registers under the parent sid with the
request transport as authority, so `subagent.list`, steer, stop and the Desktop
roster all see it.
Live repro (tui_gateway stdio, real OpenRouter reviewer):
before: registry owner_session_id=None, owner_transport=NoneType;
subagent.list -> {"subagents": []}
after: owner_session_id=<parent sid>, owner_transport=StdioTransport;
subagent.list -> [{"goal": "Review recent work", "status": "running", ...}]
* fix: address remaining P1 findings (dispatch generation, completion guard, context compressor)
- feedback_retirement.py: extend governed retirement to pr_local_ci
receipts too -- audit-pr rejects a non-OPEN PR identity outright, so a
card whose PR closes mid-audit had no other path to clear its pending
ledger row and stayed stuck forever.
- controller.py: reintroduce _dispatch_generation() (lost track of
ClaimLease.reopened during an earlier merge -- version > 1 is the same
signal) and wrap all 3 create_or_get_task() call sites, so a reclaimed
dispatch gets a fresh Kanban identity instead of returning the
pre-closure done card.
- controller.py _is_staged_auto_dispatch_task(): also require no real
"blocked" lifecycle event, so a repair worker's legitimate kanban_block
call (same status/idempotency-prefix/evidence shape as a never-run
staged card) isn't misclassified as a failed staging promotion and
bounced back to ready.
- kanban_completion_policy.py: load the bundled github_pr_feedback
package by file path instead of a bare import, so the control-plane
completion-guard fallback works even in a dispatched worker profile
that doesn't itself enable the plugin (previously ModuleNotFoundError,
uncaught).
- context_compressor.py: scan the actual handoff-expanded window
(scan.tail_start) for the current-task assignment summary instead of
the initial compression window, so a newer assignment carried by a
later-consumed handoff isn't shadowed by a stale in-window match (or
missed entirely).
- test_run_agent.py: fix a NameError from an earlier merge -- an
undefined mock_record_failure reference where the test actually needs
hermes_cli.kanban_db.block_task patched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(desktop): SSH remote backend stops following the host's sticky active_profile
A Desktop-owned `hermes serve --isolated --ssh-session-token-file ...` child
is spawned with an explicit `--profile <name>` when the connection names a
remote profile, and with no flag for the remote root home. Without the flag,
`_apply_profile_override` read the remote host's sticky `active_profile`
file and re-homed the backend into whatever profile the user last selected
on that machine's CLI. Settings then read one config.yaml while the remote
gateway wrote another, so model picks and toggles "didn't stick".
Treat the SSH token flag as a fixed-identity marker, the same way
supervisor-launched gateway children are (#74872): a Desktop backend's
profile is chosen by the client, never by the host.
Live repro (before/after, temp HERMES_HOME with active_profile=foo):
serve --isolated --ssh-session-token-file ... hermes_home=<root>/profiles/foo -> <root>
same + --profile foo hermes_home=<root>/profiles/foo (unchanged)
serve (no token file, user CLI) hermes_home=<root>/profiles/foo (unchanged)
* fix: verification evidence ledger is inert while verify_on_stop is off
The ledger in verification_evidence.db exists only to feed the verify-on-stop
guard, but the recorder kept running on every foreground terminal command and
every file edit after #53552 turned the guard off by default. Users who never
opted in still accumulated a multi-MB database (7 MB / 4.6k rows on one install).
Every ledger entry point (record_terminal_result, record_verify_run,
mark_workspace_edited, verification_status) now checks…
Fixes #102194
Summary
In persistent CLI interactive sessions, every new turn's first API call was observed missing the provider prompt cache (falling back to a ~50k token cutoff, exactly at the offset of the first message carrying injected memory/plugin context).
The
<memory-context>and plugin context is composed bycompose_user_api_contentand stamped onto the live user dict asmsg["api_content"]. In the normal flow, the turn-start crash persist writes this sidecar into SQLite so subsequent turns replay the exact wire bytes.However, when another writer materialized the current turn's user row in SQLite before
build_turn_contextcould compose the sidecar:_pending_cli_user_messageto SQLite withapi_content = NULL, stamping_db_persisted = Trueand_row_idon the dict.archive_and_compactinserts compacted messages before context injection.When the prologue subsequently runs the crash persist at line 1692,
_flush_messages_to_session_dbmarker-skips the row (_db_persisted = True). Previously, the backfill was gated strictly on in-place compaction (_preflight_compressed and _last_compaction_in_place), so the row in SQLite retainedapi_content = NULL. Upon resuming or reloading the session, clean content was replayed, the request prefix diverged from what the provider had cached, and prompt caching collapsed.Why PR #102239 and PR #102286 Fall Short
set_latest_user_api_contenton every stamped turn. Becauseset_latest_user_api_contentis positional (WHERE session_id = ? AND role = 'user' AND active = 1 AND content IS ? ORDER BY id DESC LIMIT 1) and the normal turn's row is not yet in the database when the prologue runs, repeated common inputs ("ok", "yes", "continue") match the PREVIOUS turn's row and overwrite itsapi_contentwith the new turn's sidecar, corrupting past history._db_persisted): Gated the backfill onbool(_turn_user_msg.get("_db_persisted")), but still invoked the positionalset_latest_user_api_content._db_persistedis also stamped on resumed history dicts where the specific row id is unknown; using_db_persistedto arm a positional search retains the inherent hazard of positional queries (ORDER BY id DESC LIMIT 1).The Fix: Row-Addressed Backfill via
_row_idRow-addressed store primitive: Add
SessionDB.set_message_api_content(session_id, row_id, content, api_content)inhermes_state.py, updating strictly by primary keyid = row_idwith defensive guards onrole = 'user',active = 1, andcontent IS ?, plus lone surrogate scrubbing and guards against boolean/invalid row IDs.Structural gating in
agent/turn_context.py:Check
_row_id = _turn_user_msg.get("_row_id"). Both early writers (_insert_message_rowsin compaction, andsync_flushed_message_markersafter batch flush) explicitly stamp_row_idon the live dict._has_valid_row_id = isinstance(_row_id, int) and not isinstance(_row_id, bool) and _row_id > 0._has_valid_row_id, invokeset_message_api_content(session_id, _row_id, ...)._in_place_compacted(fallback for compaction test doubles without row id), fall back toset_latest_user_api_content.api_contentcleanly in one transaction.Pure Carrier: Contains only the 3 relevant files for this fix, cleanly rebased onto current
main.Verification
tests/agent/test_api_content_row_addressed_backfill.py(11 passed):isinstance(True, int)trap), non-positive IDs, and empty sessions_db_persistedalone without_row_iddoes not arm backfilltest_set_latest_user_api_contenttests.