Skip to content

fix(compression): skip session split on plugin no-op - #58495

Closed
TurgutKural wants to merge 4 commits into
NousResearch:mainfrom
TurgutKural:fix/lcm-noop-compression-boundary
Closed

fix(compression): skip session split on plugin no-op#58495
TurgutKural wants to merge 4 commits into
NousResearch:mainfrom
TurgutKural:fix/lcm-noop-compression-boundary

Conversation

@TurgutKural

@TurgutKural TurgutKural commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Treat external context-engine last_compression_status == "noop" as a non-boundary result in _compress_context (falling back to the legacy private field for compatibility)
  • Return the compressor output and existing system prompt without rotating/splitting the session
  • Add a regression test proving plugin no-op compression does not emit a compression-boundary hook
  • Add the contributor email mapping required by the attribution check

Why

LCM can be above the host token threshold while having no eligible leaf backlog outside its protected fresh tail. In that case it returns a no-op. Previously the host still proceeded through the successful-compression session split path, creating continuation sessions without summary/DAG state.

Tests

  • python -m py_compile agent/conversation_compression.py tests/run_agent/test_compression_boundary_hook.py scripts/release.py
  • python -m pytest tests/run_agent/test_compression_boundary_hook.py -q
  • python -m pytest tests/run_agent/test_infinite_compaction_loop.py tests/run_agent/test_compression_persistence.py -q

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state P3 Low — cosmetic, nice to have labels Jul 4, 2026
@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch 2 times, most recently from fb415de to c23ab9b Compare July 4, 2026 21:11
@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch from c23ab9b to e39cd29 Compare July 12, 2026 21:41
@TurgutKural TurgutKural reopened this Jul 15, 2026
@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch from e39cd29 to 49f0d6b Compare July 15, 2026 17:25
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Rebased onto current main to clear the merge conflict; the functional diff is unchanged.

Validation: pytest tests/run_agent/test_compression_boundary_hook.py -q → 7 passed.

Current head: 49f0d6b2d.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the focused fix. The current-main premise is real: after compress() returns, agent/conversation_compression.py:769-781 only recognizes an identity no-op, while a newly allocated unchanged list can proceed into session-boundary handling at agent/conversation_compression.py:819 and the context-engine boundary hook at :987-1001.

Problems

  • tests/run_agent/test_compression_boundary_hook.py:183 returns the exact messages object. Current main already skips that path at agent/conversation_compression.py:772, so this test passes without the new last_compression_status == "noop" branch.
  • The compatibility fallback to _last_compression_status in the PR diff is not covered.

Suggested changes

  • Return list(messages) from the mock and assert it is a distinct object; this directly proves the new status branch prevents rotation.
  • Add a private-field fallback case, and document the optional status contract alongside the ContextEngine interface in agent/context_engine.py and the context-engine plugin guide.

Automated hermes-sweeper review.

Comment thread tests/run_agent/test_compression_boundary_hook.py Outdated
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Strengthened the no-op regression test so it no longer passes through current main's object-identity shortcut: the mock now returns list(messages) (equal content, different list identity). The test is also parameterized over public last_compression_status and legacy _last_compression_status, and verifies that no DB child session is created.

Validation: tests/run_agent/test_compression_boundary_hook.py8 passed.

@teknium1 teknium1 added sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 15, 2026
@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch from 6668e68 to 874b378 Compare July 17, 2026 09:44
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main (head 874b37870). All required CI checks pass.

Note on the rebase conflict: upstream/main has since added the compressed is messages no-progress check in _compress_context; the PR's last_compression_status == "noop" branch now sits before it (both checks preserved — status-flag no-op first, then object-identity no-op).

@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch from 874b378 to 8237ceb Compare July 19, 2026 09:10
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Review accepted — both points applied (commit 8237ceb77, force-pushed).

@teknium1 the hermes-sweeper review was sound, and the AGENTS.md caching rubric (no session split / no cache-invalidating rewrite when a plugin reports a no-op) backs it. Applied:

  1. Distinct-list regression test. test_plugin_noop_does_not_rotate_or_emit_boundary now sets compressor.compress.return_value = list(messages) instead of the input object, and asserts compressed is not messages and compressed == messages. This forces the new last_compression_status == "noop" branch to be the thing that prevents rotation — the old messages return would have short-circuited through the pre-existing compressed is messages identity check at agent/conversation_compression.py:979 and never touched the new code.

  2. Private-field fallback coverage. Added test_plugin_noop_private_field_fallback_does_not_rotate which deletes the public last_compression_status attribute and sets only _last_compression_status = "noop", proving the getattr(engine._last_compression_status, ...) compatibility path is honored for older engines.

  3. Contract documented. Added the last_compression_status attribute + a doc block to the ContextEngine ABC in agent/context_engine.py specifying the "noop" semantics and the public/private precedence, so context-engine authors know to emit it.

All 31 related tests pass (test_compression_boundary_hook.py, test_infinite_compaction_loop.py, test_compression_persistence.py).

Upstream check: the last_compression_status == "noop" boundary skip is not present on upstream/main (verified via git show upstream/main:agent/conversation_compression.py — no noop handling), so this fix is still required; the PR is not a duplicate of anything already on main.

Rebase note: scripts/release.py's contributor mapping was refactored on main into LEGACY_AUTHOR_MAP + contributors/emails/. I dropped the PR's AUTHOR_MAP hunk and instead added contributors/emails/turgut.kural@outlook.com per the new merge-conflict-free convention.

@TurgutKural

Copy link
Copy Markdown
Contributor Author

DEEP upstream-already-fixed analysis — verdict: STILL_OPEN (not fixed elsewhere)

I read current upstream/main (c0c76a471, which is downstream of the 94f8166dc you based on) and the PR's own branch. The no-op-on-distinct-list path is still open on main. The fix in this PR is genuinely needed.

What main does today (agent/conversation_compression.py)

After context_compressor.compress() returns, rotation is gated by exactly three checks:

# Gate (upstream/main) Line Catches a plugin no-op that returns list(messages)?
1 if getattr(agent.context_compressor, "_last_compress_aborted", False) 938 ❌ Only set on aux-LLM summary failure, not a clean plugin no-op
2 if compressed is messages: (identity check) 958 ❌ A plugin returning a new equal list (list(messages)) is not the same object → fails
3 if not compressed: 969 ❌ No-op returns a non-empty equal list, not an empty one

If a plugin's compress() reports "nothing changed" by returning a freshly-built equal list (as hermes-lcm-style engines plausibly do, and exactly what this PR's regression test mocks at test_compression_boundary_hook.py:189 via compressor.compress.return_value = list(messages)), none of the three gates fire, so execution falls through to the rotation block (lines 1066+) and agent.session_id is rotated / session split — the churn this PR targets.

Signals that do NOT already cover the gap

  • _last_compression_made_progress is captured (lines 926-928) but is used only to arm the effectiveness verdict/deferral (line 1297) — it does not gate rotation. So "made no progress" does not by itself skip the split.
  • No len(compressed) == len(messages) equality gate exists anywhere in the rotation path (only referenced in docstrings about the caller's retry loop, lines 623/710/937).
  • No last_compression_status / _last_compression_status / did_compress / compression_result / no_change signal exists anywhere on upstream/main (git grep: 0 occurrences).
  • The plugin contract (agent/context_engine.py) has no no-op sentinel in compress()'s return — it just returns List[Dict]. Built-in ContextCompressor dodges the bug only because it returns the same messages object on a no-window case (context_compressor.py:3338 etc.), which the identity gate at line 958 catches. A third-party plugin that builds a new list is not afforded that protection.

Conclusion

This is not already fixed by another mechanism. The PR correctly adds the missing last_compression_status == "noop" gate (public attribute preferred, _last_compression_status private fallback) ahead of the identity gate, plus documents the contract on ContextEngine. Leaving open for the maintainer — the change is in-scope and the gap is real.

(Filed from the delegated deep-analysis pass; no code touched on main, inspected in an isolated worktree.)

@teknium1 teknium1 added area/sessions Session lifecycle, resume, persistence, history area/compression Context compression and continuation sessions labels Jul 19, 2026
@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch from 8237ceb to e6c4e17 Compare July 20, 2026 08:23
@GottZ

GottZ commented Jul 20, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Thanks for the focused fix — I traced it against main and it holds up. The gap is real: after compress() returns, rotation is gated only by _last_compress_aborted, the compressed is messages identity check, and the empty-transcript check (agent/conversation_compression.py:1004, :1046, :1064). A plugin that signals a no-op by returning a freshly-built equal list slips past all three and triggers an unwanted session split. The new last_compression_status == "noop" gate at :1022 sits correctly between the abort and identity checks, and the change is safely opt-in — no built-in engine sets the flag (git grep on main = 0), so legitimate built-in splits are unaffected. Loop-detection also stays intact since callers key on len(returned) == len(input), not object identity (:689, :1004), so returning a distinct equal-length list is still recognized as a no-op.

Two small notes, neither blocking:

  1. The host reads the status flag but never resets it before calling compress(). If a plugin sets "noop" once and forgets to clear it on a later successful pass, a legitimate split would be suppressed. Since the contract already asks engines to report per-pass, this is on the plugin — but a host-side reset to "" before compress() would make the gate self-defending.
  2. The regression tests assert session_id constancy and the absence of the compression boundary hook, which is solid; the PR description mentions verifying "no DB child session is created" — if that's intended, an explicit assertion on the session DB would match the description exactly.

One actionable item: main has since refactored the pre-compress region of _compress_context, and as of today this branch no longer merges cleanly (git merge-tree reports a content conflict in agent/conversation_compression.py), so a rebase is needed. It will also interact with #58512 (same file/subsystem): the two changes are logically independent (that PR rebinds before compress(), this one skips after), but both append methods just before class TestSessionCompressEvent in test_compression_boundary_hook.py — worth landing them in a coordinated order.

@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch from e6c4e17 to 725b102 Compare July 21, 2026 06:41
@TurgutKural
TurgutKural force-pushed the fix/lcm-noop-compression-boundary branch from d1df7ba to 6492730 Compare July 27, 2026 04:14
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Closing as absorbed by upstream. The no-op compression guard this PR added is now present directly in compress_context (upstream/main, lines ~1865-1883): if compressed == messages_before_compression: Compression made no progress — skipping boundary rewrite. The problematic structure this PR guarded against has been resolved upstream via a different (object-identity vs semantic-equality) mechanism. No functional gap remains. Thanks for the review.

Skywind5487 added a commit to Skywind5487/hermes-agent that referenced this pull request Aug 17, 2026
Port the explicit plugin no-op session-boundary guard onto current upstream lifecycle seams and add a regression for the residual case not covered by semantic equality: cleanup-changed active context reported as noop must be adopted without rotating the session.

Prior art: NousResearch#58495. Equal-copy no-progress handling remains upstream-owned by NousResearch#67938.
TurgutKural added a commit to TurgutKural/hermes-agent that referenced this pull request Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants