Skip to content

fix(compression): rebind context engine before compaction - #58512

Closed
TurgutKural wants to merge 2 commits into
NousResearch:mainfrom
TurgutKural:fix/lcm-compression-rebind-before-compress
Closed

fix(compression): rebind context engine before compaction#58512
TurgutKural wants to merge 2 commits into
NousResearch:mainfrom
TurgutKural:fix/lcm-compression-rebind-before-compress

Conversation

@TurgutKural

Copy link
Copy Markdown
Contributor

Summary

  • Rebind external context engines to the host agent session before invoking compress() when they are still bound to a stale side-channel session.
  • Add a regression test that asserts the stale-bound engine is rebound before compression and still receives the normal old->new compression-boundary hook afterward.

Why

LCM compaction writes DAG/store state under the engine's current session id. Reverse-engineer profile logs show compression can run while the shared LCM engine is still bound to an older/side-channel session, causing summary nodes to be attributed to the wrong session and later carry-over to skip because old_session_id does not match the bound session.

Tests

  • python -m py_compile agent/conversation_compression.py tests/run_agent/test_compression_boundary_hook.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
  • python -m ruff check agent/conversation_compression.py tests/run_agent/test_compression_boundary_hook.py

@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch from 264931c to ab61641 Compare July 4, 2026 21:49
@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 reopened this Jul 15, 2026

@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 isolating the pre-compression ordering concern. Current main still calls compress() directly at agent/conversation_compression.py:721, so the proposed guarantee is not already present.

Problems

  • agent/conversation_compression.py:629-643 derives session state from current_session_id / _session_id, neither of which is part of ContextEngine's contract (agent/context_engine.py:142-166). This makes the host behavior engine-specific and silently bypassable.
  • on_session_start() is documented as a new-session callback (agent/context_engine.py:144-149), while initialization supplies hermes_home, model, and context_length (agent/agent_init.py:1978-1984). The proposed mid-session call supplies only platform and conversation id.
  • The regression test creates a MagicMock.current_session_id (tests/run_agent/test_compression_boundary_hook.py:182-201), so it proves the new convention rather than an engine contract.

Suggested changes

  • Introduce and test an explicit, documented context-engine rebind/readiness contract instead of reading private state and reusing the new-session lifecycle hook.
  • Reconcile this with linked PR #62374's per-agent cloning direction before relying on shared mutable engines.

Automated hermes-sweeper review.

Comment thread agent/conversation_compression.py Outdated
@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch from ab61641 to 3b39538 Compare July 15, 2026 17:43
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and hardened the pre-compression rebind against the current LCM session contract. The binding check now prefers bound_session_id, then legacy _session_id, with current_session_id only as a compatibility fallback; this covers the case where the operator-facing foreground ID is current while the engine is still actually bound to a stale side-channel session.

A known-stale binding now fails closed if on_session_start() cannot rebind, so compress() cannot write state under the wrong session. Added regression coverage for both precedence and rebind failure.

Validation: tests/run_agent/test_compression_boundary_hook.py + tests/run_agent/test_413_compression.py34 passed.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jul 15, 2026
@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch from 3b39538 to c892047 Compare July 17, 2026 09:48
@TurgutKural

Copy link
Copy Markdown
Contributor Author

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

@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch from c892047 to ad0a5d5 Compare July 19, 2026 09:07
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Thanks for the careful triage. Rebased onto current upstream/main (no conflict) and force-pushed. Addressing the substance:

  • Factually correct, but the scope of a full contract is a maintainer call. You are right that current_session_id / _session_id and on_session_start are not part of the documented ContextEngine interface (agent/context_engine.py:144). A dedicated rebind(agent_session_id) / readiness contract would be cleaner and engine-agnostic. That is a genuine design improvement, but it is also a larger refactor touching the LCM engines and their init (agent/agent_init.py), which this bug-fix PR was not scoped to do, and it intersects the per-agent cloning direction on linked PR fix(context-engine): clone plugin engines per agent #62374. That coordination belongs with a human maintainer, not a unilateral rewrite of a contributor's targeted fix (AGENTS.md: preserve contributor credit via rebase-merge, and taste-based reshaping stays with the maintainer).

  • On the immediate correctness concern: the guard is defensive and opt-in. It only acts when (a) a bound session id exists, (b) it differs from the host session, and (c) the engine exposes on_session_start. Engines that do not expose those attributes are simply skipped (hasattr guard plus a bare except to logger.debug). So it cannot break a conforming engine; it narrows to exactly the shared-LCM-with-stale-binding case the reverse-engineer profile logs showed. The MagicMock test asserts the new ordering convention (rebind-before-compress, then the compression-boundary hook afterward), which is precisely the invariant this PR guarantees today.

  • Prompt-cache invariant: this change lives entirely inside _compress_context, i.e. the compression path, which is the one sanctioned prompt-cache-invalidation exception. It does not mutate past context, swap toolsets, or rebuild the system prompt mid-conversation outside compression, so it does not introduce a new cache-breaking surface.

Proposal: keep this targeted fix merged to close the reported wrong-session attribution now, and open a follow-up to formalize the rebind contract (covering PR #62374's cloning direction) as separate, larger work. Happy to draft that follow-up if useful.

@TurgutKural

Copy link
Copy Markdown
Contributor Author

Deep upstream-already-fixed analysis — VERDICT: STILL_OPEN

I read upstream/main (94f8166) directly, not just a shallow grep. The stale-session-binding gap the PR addresses is not closed by any other mechanism on main.

What main does today

  • ContextEngine.compress()agent/context_engine.py:87-101 — signature is (self, messages, current_tokens=None, focus_topic=None). No session_id parameter. The engine operates on whatever _session_id / current_session_id it was last bound to.
  • Engine session binding is set once, at agent construction: agent/agent_init.py:1926-1929 calls bind_session_state(session_db=…, session_id=agent.session_id), then on_session_start(agent.session_id…) at line ~2031. Nothing re-binds the engine before a mid-conversation compression.
  • During compress_context() (agent/conversation_compression.py), the only on_session_start for the engine fires after compress() returns — lines 1216-1223, in the post-boundary block, gated on _is_boundary and passing boundary_reason="compression". This is too late to affect the compress() call itself, and does not run if _is_boundary is false.
  • compress() itself reads self._session_id in several places (get_active_compression_failure_cooldown, _load_fallback_compression_streak, _record_fallback_compression_streak, etc. in context_compressor.py ~lines 1048-1137) — all gate on getattr(self, "_session_id", ""). If that binding is stale (post session-swap, subagent-shared engine, cross-session bleed), the compressor reads/writes guard state for the wrong session. There is no assert and no rebind.
  • No ensure_engine_bound / pre_compression_rebind helper exists anywhere: git grep ensure_engine_bound|ensure_bound|pre.compression.rebindnothing.
  • No in-repo LCM engine exists — plugins/context_engine/ contains only the discovery loader __init__.py. The LCM engine is the external hermes-lcm plugin (referenced as hermes-lcm#68). So main cannot guarantee any third-party engine rebinds automatically.

The specific code gap

compress() is invoked at agent/conversation_compression.py:907 (and the fallback at :912) with no reordering or rebind of the engine to agent.session_id beforehand. The PR's pre-compress rebind block (the ~24-line try: checking current_session_id/_session_id vs agent.session_id then calling on_session_start) is absent on upstream/main (grep for rebind/_bind_sid in the pre-compress region returns no matches).

Conclusion

STILL_OPEN. The stale-binding problem can still occur: the engine is bound once at construction and the only re-bind during compression is post-compress(), so a shared/stale-bound engine still performs its guard reads/writes (and any plugin DAG/store writes) keyed to the wrong session during the compression call. The PR's defensive rebind is a legitimate, still-needed fix. Leaving open for maintainer review.

(Reviewed against 94f8166.)

@teknium1 teknium1 added the area/compression Context compression and continuation sessions label Jul 19, 2026
@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch from ad0a5d5 to 0a37883 Compare July 20, 2026 08:23
@GottZ

GottZ commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Thanks for the detailed follow-up. Two points where the current head (0a378831) diverges from the description, plus a rebase note:

  1. Fail-open, not fail-closed. The pre-compress block ends in except Exception as _bind_err: logger.debug(...) and then falls through to compress(). So if on_session_start() raises during the rebind, compression still runs under the stale binding — the exact case the change is meant to prevent. The 2026-07-15 comment describes fail-closed-on-rebind-failure and a bound_session_id → _session_id → current_session_id precedence, but the head reads current_session_id first, then _session_id, with no bound_session_id and no fail-closed path. Could you reconcile the code with the description (or restore the hardened variant)?

  2. Hook overload without boundary_reason. The post-compression notification deliberately passes boundary_reason="compression" so plugin engines preserve DAG lineage instead of re-initializing (hermes-lcm#68). The new pre-compress rebind calls on_session_start(session_id, platform=…, conversation_id=…) with no boundary_reason, so a conforming engine cannot distinguish a genuine new-session start (reset state) from a mid-session rebind (keep state). This is the same contract gap noted in review — reading current_session_id/_session_id off the engine is outside the documented ContextEngine interface.

  3. Coverage. The single added test mocks the compressor end-to-end and asserts the host ordering convention (rebind-before-compress); it doesn't exercise a real engine's attribution, and I don't see the precedence/rebind-failure tests mentioned in the comment. A test that drives the failure branch would pin down whichever fail semantics you settle on.

  4. Rebase. main moved past your rebase base; the _compress_context pre-compress region was refactored (on_pre_compress now lives inside the lock try-block, compress() goes through _supported_compression_kwargs), so the branch currently conflicts. A rebase is needed, and it will collide with your own fix(compression): skip session split on plugin no-op #58495 in the same file/region — worth landing them in a coordinated order.

The underlying gap is real: on current main nothing rebinds the engine before compress() (the only on_session_start fires post-compress). A minimal, contract-respecting fix plus a follow-up for the formal rebind contract (coordinated with #62374) seems like the right split.

@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch 2 times, most recently from 2715a2c to 3944d58 Compare July 22, 2026 06:03
@TurgutKural

Copy link
Copy Markdown
Contributor Author

All four items addressed:

  1. Fail-closed — the except block now returns messages unchanged (compression skipped) instead of falling through to compress() under a stale binding. Logged at WARNING level with the session id and error.

  2. boundary_reason="rebind" — the pre-compress rebind call now passes boundary_reason="rebind" so conforming engines can distinguish a mid-session rebind (keep state) from a genuine new-session start (reset state). This addresses the contract gap noted in review.

  3. Precedence — now reads bound_session_id (public, contract-respecting) → _session_id (legacy private) → current_session_id (older fallback), matching the documented order.

  4. Coverage — added test_rebind_failure_skips_compression_fail_closed (on_session_start raises → compress() never called, messages returned unchanged) and test_rebind_uses_bound_session_id_precedence (bound_session_id matches → no rebind even when _session_id/current_session_id are stale).

On the rebase note: acknowledged — this will conflict with #58495 in the same file/region. Will coordinate landing order. The minimal fix here is scoped to the pre-compress rebind block; the formal rebind contract follow-up (coordinated with #62374) is a separate effort.

@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch 5 times, most recently from 5358fcb to d4e2f8c Compare July 25, 2026 04:14
@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch 5 times, most recently from b930959 to 27a6178 Compare July 30, 2026 03:37
@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch from 27a6178 to 0bb452d Compare July 30, 2026 15:47
@TurgutKural

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main (7965462). The CI failure was a pre-existing vercel sandbox test issue now fixed on main — not related to this PR's compression rebind changes. Fail-closed behavior and boundary_reason='rebind' preserved from prior review fixes.

@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch 8 times, most recently from 92fcde2 to c7601df Compare August 6, 2026 03:52
@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch 3 times, most recently from 42b5fa9 to 348dc26 Compare August 13, 2026 03:48
Address GottZ review (4 items):
1. Fail-closed: if on_session_start raises during pre-compress rebind,
   compression is skipped (messages returned unchanged) instead of
   running under a stale binding.
2. boundary_reason='rebind' passed to on_session_start so conforming
   engines can distinguish a mid-session rebind (keep state) from a
   genuine new-session start (reset state).
3. Precedence: bound_session_id (public) → _session_id (legacy) →
   current_session_id (older fallback), matching the documented
   contract-respecting order.
4. Coverage: added test_rebind_failure_skips_compression_fail_closed
   and test_rebind_uses_bound_session_id_precedence.
@TurgutKural
TurgutKural force-pushed the fix/lcm-compression-rebind-before-compress branch from 348dc26 to 53f5cb4 Compare August 14, 2026 03:50
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 comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades 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