Skip to content

Release M — v0.51.37 — Compression / lineage backend (6 PRs) - #2020

Merged
nesquena-hermes merged 15 commits into
masterfrom
stage-331
May 10, 2026
Merged

Release M — v0.51.37 — Compression / lineage backend (6 PRs)#2020
nesquena-hermes merged 15 commits into
masterfrom
stage-331

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Release M — v0.51.37 — Compression / lineage backend

Six contributor PRs from four authors. All on session lifecycle: compression boundary persistence, profile stamping, lineage collapse, fork-source marker, lineage report endpoint, and continuation-transcript stitching.

Constituent PRs

PR Author Concern LOC
#2004 @franksong2702 Persist compression boundary summary metadata +160/-3
#2006 @qxxaa Stamp s.profile on continuation session after compression +34/-1
#2011 @ai-ag2026 Sidebar lineage collapse prefers latest compressed segment +49/-1
#2012 @dso2ng Read-only /api/session/lineage-report/<sid> diagnostic endpoint +363/-0
#2014 @ai-ag2026 session_source="fork" marker; keep forks out of compression-lineage collapse +28/-0
#2015 @Jellypowered Stitch continuation-lineage transcripts in get_cli_session_messages() +139/-7

Cross-PR interaction surface

This is the highest-coupling stage of the batch — every PR touches session machinery:

All hunks at distinct anchors; stage merge clean with no conflicts.

Tests

  • Pre-stage: 5049 passing
  • Post-stage: 5058 passing (+9 net), 0 regressions, 157s
  • New test files: test_session_lineage_collapse.py, test_session_lineage_full_transcript.py, test_session_lineage_report.py

Especially keen for your eyes on

  1. fix: stamp profile on continuation session after context compression #2006 profile stamping correctness in multi-threaded path. Memory writes silently going to the wrong profile is a real data-correctness bug that this fixes; verify there's no race when a fast tab switch could mid-write the resolved profile.
  2. Stitch continued session transcripts in WebUI #2015 lineage walk — verify cycle protection (max-depth or visited-set) on the continuation-lineage chain.
  3. feat: add read-only session lineage report endpoint #2012 new endpoint/api/session/lineage-report/<sid> is a new surface. Auth gating + SID validation worth a look.
  4. Persist compression boundary summary for reload UI #2004 + fix: stamp profile on continuation session after context compression #2006 ordering — both touch the auto-compression block; verify the resolved code does both writes (anchor metadata persistence AND profile stamping) on the continuation session.

Closes

#1833

Opus advisor

Running on stage diff. Will update with verdict before merge.

Frank Song and others added 13 commits May 10, 2026 16:45
When context compression fires, the agent rotates to a new session_id.
The compression migration block correctly migrates the session lock,
SESSION_AGENT_CACHE, SESSIONS dict, and the session file rename, but
does not ensure s.profile is set on the continuation session.

On the next request, _run_agent_streaming resolves the profile via:

    get_hermes_home_for_profile(getattr(s, 'profile', None))

With s.profile == None this falls back to the default profile's
HERMES_HOME. Memory tool calls then read and write the wrong profile's
MEMORY.md — confirmed by investigation: session 0dfefb (continuation
after compression from a troubleshooting profile session) read memory
at 16% / 1,184 chars with 4 entries, while the troubleshooting profile's
actual state was 72-77% / 5,000+ chars. That reading could only come
from the default profile's bank. Subsequent replace operations failed
because the target entries existed only in the troubleshooting profile.

There are two failure paths:

1. In-memory: if s.profile was None from the start (legacy session or
   one created before this fix), the continuation session object carries
   null through the current request.

2. Persistence: s.save() persists "profile": null to the continuation
   session's JSON file (profile is in METADATA_FIELDS, models.py ~408).
   On the next request, Session.load(new_sid) reads it back as null and
   get_hermes_home_for_profile(None) falls back to the default profile.

Fix: capture _resolved_profile_name at request entry (~line 2019),
immediately after profile home resolution. This is the only point where
profile context is reliable: s.profile if already set, otherwise
get_active_profile_name() — which at that point reads thread-local
storage (_tls.profile) correctly set by the HTTP handler thread via
set_request_profile(). Calling get_active_profile_name() at compression
time instead would be unsafe: the streaming thread is a separate
threading.Thread, does not inherit TLS, and the call would fall back to
the process-global _active_profile which may belong to a different
concurrent tab.

Stamp s.profile in the compression migration block immediately after
s.session_id = new_sid. Guarded by `if not s.profile` so sessions that
already have a profile set are unaffected. A logger.info line records
when the stamp fires, making future investigation straightforward.

Fixes: memory writes bleeding into default profile after compression
Reproduces: reliably on any long non-default profile session that hits
the compression threshold (default: 0.80 context fill)
@nesquena-hermes
nesquena-hermes requested a review from nesquena May 10, 2026 17:34
@nesquena-hermes

Copy link
Copy Markdown
Collaborator Author

Opus advisor verdict — SHIP-WITH-CAVEATS

Full review run on stage diff with brief at /tmp/stage-331-brief.md. Opus verified each PR directly from repo HEAD + diff with --thinking enabled.

Summary of findings

#2004 (anchor metadata persistence) — Field added to Session.__init__, _PERSISTED_FIELDS, AND compact() return dict. Round-trips through save/load. Auto-compression block sets fields inside if _compressed:s.save() is called downstream at api/streaming.py:3185 so metadata persists. Manual compression endpoint sets fields and calls s.save() directly. ✓

  • Note: minor code duplication — _visible_messages_for_compression_anchor/_compression_anchor_message_key added module-level in streaming.py while _visible_messages_for_anchor/_anchor_message_key already exist as nested functions in _handle_session_compress. Two near-identical implementations.

#2006 (profile stamping)_resolved_profile_name captured at streaming-thread entry (around line 2103), TLS-correct because the streaming thread has not had any TLS set on it yet. Stamping happens at s.session_id = new_sid site, inside the same atomic block. String assignment is atomic in CPython, so concurrent readers don't tear. ✓

  • ⚠ Caveat: the fallback get_active_profile_name() path still has the multi-tab race the inline comment warns about. New sessions (with s.profile already populated) are fine; old sessions where s.profile is None still risk picking up another tab's process-global profile. Documented limitation, not a regression.
  • Order vs Persist compression boundary summary for reload UI #2004: profile stamping happens before anchor-metadata block; both finalize before the single s.save(). No interleaving issue. ✓

#2012 (lineage report endpoint) — Auth enforced upstream by check_auth() in server.py:do_GET. Path uses query parameter (not path component) → no path traversal vector. SQL parameterized via cur.execute(... WHERE s.id = ?, (row_id,)). Profile isolation: _active_state_db_path() resolves to active profile's state.db. Walk: 20-hop bound, seen set cycle protection, uses _is_continuation_session. ✓

#2015 (lineage stitching) — Walk: 20 hops, seen = {current_id} cycle guard, bails on _is_continuation_session = False. Same logic as #2012 reader — both use parent_session_id + _is_continuation_session consistently. Query parameterized. ✓

  • ⚠ Caveat: merged-messages dedup at api/routes.py:3033–3055 keys on (role, content, timestamp, tool_call_id, tool_name). Two distinct messages with identical tuples would collapse. Pathological but possible with tool re-runs.

#2011 + #2014 composition_sessionLineageKey returns null for session_source === 'fork' before any group lookup. Strict-equality check, so falsy/undefined session_source does not match — legacy forks created before this PR still group with their parent. Composition with segment-count sort holds: forks never reach the comparison. ✓

Caveats (none blocking, all suitable for follow-ups)

  1. Legacy fork classification — pre-fix: keep explicit fork sessions out of compression lineage #2014 fork sessions lack session_source="fork" and remain grouped with parent lineage. If retroactive exclusion was intended, a one-time migration is missing.
  2. Merged-messages dedup partial keyroutes.py:3033–3055 could collapse two genuinely distinct messages sharing the same tuple. Pathological but possible.
  3. Helper duplication_visible_messages_for_anchor / _visible_messages_for_compression_anchor worth consolidating in a follow-up.
  4. fix: stamp profile on continuation session after context compression #2006 fallback race — bounded by the fix but not eliminated when s.profile is None.

Verdict: SHIP-WITH-CAVEATS

No NEEDS-FIX issues. Compression block correctly sequences profile stamping → session-id rotation → anchor metadata → single s.save(). New endpoint is auth-gated and profile-isolated. Both lineage walkers share _is_continuation_session and bound at 20 hops with cycle protection.

@nesquena nesquena left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — end-to-end ✅ (clean approve, 6-PR session-lifecycle release train all verified)

What this ships

Release M / v0.51.37 — release-train PR aggregating 6 contributor PRs from 4 authors. All on session lifecycle: compression boundary persistence, profile stamping, lineage collapse, fork-source marker, lineage report endpoint, continuation-transcript stitching. +800/-12 LOC across 14 files.

PR Author Concern LOC
#2004 @franksong2702 Persist compression boundary summary metadata +160/-3
#2006 @qxxaa Stamp s.profile on continuation session after compression +34/-1
#2011 @ai-ag2026 Sidebar lineage collapse prefers latest compressed segment +49/-1
#2012 @dso2ng Read-only /api/session/lineage/report diagnostic endpoint +363/-0
#2014 @ai-ag2026 session_source="fork" marker; keep forks out of compression-lineage collapse +28/-0
#2015 @Jellypowered Stitch continuation-lineage transcripts in get_cli_session_messages() +139/-7

Closes #1833.

Cross-tool trace (verified against fresh hermes-agent tarball)

All 6 PRs touch WebUI session-lifecycle surfaces. The agent doesn't read compression_anchor_* fields — they're WebUI-internal display state. parent_session_id and session_source ARE shared with the agent's state.db.sessions table; the agent writes these through its own continuation/branching code paths. The PR's READS of state.db (lineage report, transcript stitching) are read-only and parameterized.

Cross-tool surface: read-only against state.db (which is canonically owned by hermes-agent). No new write paths introduced. ✓

End-to-end traces

#2004 — compression anchor metadata: New field compression_anchor_summary added to Session.__init__ (api/models.py:332), _PERSISTED_FIELDS (api/models.py:417), and compact() (api/models.py:578). Round-trips through save/load. Auto-compression block at api/streaming.py:3115-3128 sets compression_anchor_visible_idx, compression_anchor_message_key, compression_anchor_summary AFTER _compressed=True; manual compression endpoint at api/routes.py:7757-7765 sets them and calls s.save() directly. Both paths converge on a single save point. ✓

#2006 — profile stamping: _resolved_profile_name captured at api/streaming.py:2103-2118, immediately after _profile_home resolution. Used at api/streaming.py:3082-3088 inside the compression migration block:

s.session_id = new_sid
if not s.profile and _resolved_profile_name:
    s.profile = _resolved_profile_name
    logger.info("Stamped profile=%r on continuation session %s after compression", ...)
with LOCK:
    if old_sid in SESSIONS:
        SESSIONS[new_sid] = SESSIONS.pop(old_sid)

The if not s.profile guard ensures existing-profile sessions aren't overwritten. CPython string assignment is atomic — concurrent readers don't tear. The capture happens on the streaming thread (which doesn't inherit TLS from the HTTP handler thread, per the PR's inline comment), so the fallback get_active_profile_name() path falls back to the process-global. For multi-tab scenarios where s.profile is None, this is bounded — see follow-up #4. ✓

#2012 — lineage report endpoint:

  • Auth: check_auth(self, parsed) runs at server.py:133 BEFORE dispatching to handle_get. The new endpoint is auth-gated.
  • Path: /api/session/lineage/report?session_id=<sid> — query param, not path component. No path-traversal vector.
  • SQL: cur.execute("... WHERE s.id = ?", (row_id,)) — parameterized.
  • Profile isolation: _active_state_db_path() resolves to active profile's state.db.
  • Walk: max_hops=20 bound, seen set with parent_id in seen cycle guard, uses _is_continuation_session(parent, current) filter.
  • Output: read-only structure with mutation: False, no destructive proposals. ✓

#2015 — lineage transcript stitching: get_cli_session_messages at api/models.py:1666-1755 walks parent_session_id for up to 20 hops with seen cycle guard, gates on _is_continuation_session(parent_dict, current). SQL: parameterized via placeholders = ', '.join('?' for _ in session_chain) with positional binding. The merged dedup at api/routes.py:3033-3055 keys on (role, content, timestamp, tool_call_id, tool_name) — pathological collision possible for two distinct same-tuple messages but bounded since timestamp typically has sub-second precision. ✓

#2011 + #2014 sidebar composition: _sessionLineageKey at static/sessions.js:1979 returns null for s.session_source === 'fork' BEFORE any group lookup. Strict-equality so legacy forks (no session_source) still group with parent — backward-compatible. _collapseSessionLineageForSidebar at static/sessions.js:2102-2114 sorts by _compression_segment_count first, falls back to timestamp. ✓

Other audit — things that are correct already

  • Profile stamping atomicity: capture at request entry → stamp at compression boundary → both on the same streaming thread. CPython string assignment is atomic. No tearing.
  • Compression block ordering (PR body's tagged concern): #2006 stamps profile FIRST (line 3082), then #2004 sets anchor metadata (line 3115+), then single s.save() at line 3185. Both writes finalize before persistence. No interleaving issue.
  • Lineage walk cycle protection: BOTH read_session_lineage_report and get_cli_session_messages use the same pattern: seen = {sid}, if parent_id in seen: break, max 20 hops via range(max_hops). Identical shape.
  • Lineage report endpoint auth: confirmed via check_auth() at server.py:133 BEFORE handle_get dispatch. All GET endpoints are auth-gated by the same path.
  • SQL parameterization: every cur.execute uses ? placeholders with positional binding. No string interpolation of user input.
  • _is_continuation_session consistency: both lineage walkers (#2012 reader, #2015 stitcher) use the same predicate. Forks (session_source="fork") don't satisfy the continuation predicate, so they correctly don't get stitched into the lineage transcript even with #2014's marker.
  • Backward compat for legacy forks: pre-#2014 fork sessions lack session_source="fork". The strict-equality if (s.session_source==='fork') at sessions.js:1980 doesn't match undefined/missing, so legacy forks still group with parent. No retroactive migration needed.
  • Merged-messages dedup: pathological tuple collision possible but bounded; sub-second timestamps prevent same-instant collisions in practice.
  • compression_anchor_summary truncation: _compact_summary_text at api/streaming.py:1539 caps at 320 chars with ellipsis. Bounded UI string.
  • Helper duplication caveat (Opus advisor): _visible_messages_for_anchor (nested in _handle_session_compress) vs _visible_messages_for_compression_anchor (module-level in streaming.py). Two near-identical implementations — worth consolidating in follow-up.
  • session_source="fork" is set ONLY on /api/session/branch calls at api/routes.py:4267. Other session creation paths (compression continuation, /btw, gateway) don't set this field, so the strict-equality check correctly distinguishes forks from continuations.
  • _lineage_report_row output shape: mutation: False, found: bool, segments: [...], children: [...], manual_review: bool. Pure read-only structure. Future UI can consume without server-state risk.
  • No XSS surface: lineage report returns JSON; the only string fields are title, source, end_reason from state.db — server-controlled (the agent owns state.db).
  • .gitignore adds .venv/ — minor housekeeping. Doesn't affect runtime.

Edge-case trace

Scenario Expected Actual
Auto-compression on default profile s.profile already set; stamp guard skips
Auto-compression on non-default profile continuation session inherits s.profile via stamp
Auto-compression on legacy session with s.profile=None falls back to get_active_profile_name(), may be process-global (multi-tab race) ⚠ documented limitation
Compression card after page reload compression_anchor_summary rendered from persisted state
Manual compression via /api/session/compress sets all 3 anchor fields + saves
Lineage report with cycle in parent chain seen set breaks the loop, manual_review: True
Lineage report with > 20 hops range(20) bound exits, manual_review: True
Lineage report on missing session _empty_lineage_report with found: False → 404
Lineage report SQL injection attempt parameterized ? binding rejects
Lineage report unauth check_auth 401s before dispatch
Continuation transcript stitching for messaging session walks parent chain, dedups merged
Continuation transcript on session with no parent returns own messages only
Sidebar collapse with touched parent vs newer compressed tip newer (higher _compression_segment_count) wins
Sidebar collapse with explicit fork in same lineage_root fork excluded via session_source==='fork' early return
Legacy fork (no session_source) groups with parent (backward-compatible)
Branch endpoint sets session_source="fork" yes, at routes.py:4267
Cross-tool: agent reads compression_anchor_* fields n/a — WebUI-internal only

Tests

  • tests/test_session_lineage_collapse.py: pass — new test for highest-segment-count vs touched-parent.
  • tests/test_session_lineage_full_transcript.py (new): pass — sidecar + lineage merge integration test.
  • tests/test_session_lineage_report.py (new): 196 LOC of behavioural tests against a real sqlite state.db fixture, covering bounded read-only output, cycle protection, hop limit, branch detection.
  • tests/test_465_session_branching.py: pass — fork-marker + sidebar guard tests.
  • tests/test_auto_compression_card.py: pass — sessionCompressionSummary fallback in renderMessages.
  • tests/test_sprint46.py: pass — pre-existing tests still green.
  • Full suite: 4952 passed, 59 skipped, 3 xpassed. 9 pre-existing macOS-shell failures unchanged from baseline (this branch predates stage-330's bash 3.2 fix).

Minor observations (non-blocking — Opus advisor follow-ups)

  1. Legacy fork classification — pre-#2014 fork sessions lack session_source="fork" and remain grouped with parent lineage. If retroactive exclusion is intended, a one-time migration is needed. Likely acceptable since the strict-equality check is intentional backward-compat.
  2. Merged-messages dedup partial key at api/routes.py:3033-3055 could collapse two genuinely distinct messages sharing (role, content, timestamp, tool_call_id, tool_name). Pathological but possible. Would need richer dedup key (e.g., message_id) for full safety.
  3. Helper duplication_visible_messages_for_anchor (nested in _handle_session_compress) vs _visible_messages_for_compression_anchor (module-level) — worth consolidating.
  4. #2006 fallback race — for s.profile=None legacy sessions, fallback to get_active_profile_name() may pick up process-global from another tab. Bounded by the fix (most sessions have s.profile set), not eliminated. Documented in inline comment.
  5. get_cli_session_messages SQL widening — went from WHERE session_id = ? to WHERE session_id IN (?, ?, ...). Larger result set for stitched transcripts. Bounded by the 20-hop walk.
  6. Lineage report endpoint URL shape: PR body says /api/session/lineage-report/<sid> but actual code is /api/session/lineage/report?session_id=<sid> — minor inconsistency between docs and impl. Code is correct.

Recommendation

Approved. Highest-coupling release of the batch — every PR touches session machinery — and stage merge clean across api/routes.py (4 PRs), api/streaming.py (2 PRs), api/models.py (2 PRs). All hunks at distinct anchors, no conflicts.

The two most critical fixes are independent and correct:

  • #2006 profile stamping prevents memory writes from silently bleeding into the default profile's MEMORY.md after auto-compression. Verified via the inline comment trace and atomicity reasoning.
  • #2012 lineage report endpoint correctly auth-gated, parameterized, profile-isolated, with bounded walks and cycle protection — matches the same shape as the existing #2015 stitcher.

Cross-tool safe (read-only against state.db, which the agent owns). No agent-side regression. The 4 Opus advisor caveats are all follow-up items, not blockers.

Parked at approval — ready for the release agent's merge/tag pipeline.

@nesquena-hermes
nesquena-hermes merged commit a42adbe into master May 10, 2026
3 checks passed
@nesquena-hermes
nesquena-hermes deleted the stage-331 branch May 11, 2026 06:26
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
Release M — v0.51.37 — Compression / lineage backend (6 PRs)
bernyforce pushed a commit to bernyforce/hermes-webui that referenced this pull request Jul 29, 2026
Release M — v0.51.37 — Compression / lineage backend (6 PRs)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants