Skip to content

feat(lcm): Lossless Context Management as ContextEngine plugin#6464

Closed
dusterbloom wants to merge 41 commits into
NousResearch:mainfrom
dusterbloom:feat/lcm-as-plugin
Closed

feat(lcm): Lossless Context Management as ContextEngine plugin#6464
dusterbloom wants to merge 41 commits into
NousResearch:mainfrom
dusterbloom:feat/lcm-as-plugin

Conversation

@dusterbloom

@dusterbloom dusterbloom commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds Lossless Context Management (LCM) as a pluggable ContextEngine, replacing the monolithic ContextCompressor with a DAG-based message store that supports compaction, expansion, pinning, and forgetting
  • Implements Dense Associative Memory (DAM) and Holographic Reduced Representation (HRR) retrieval layers for semantic search over compressed context
  • Introduces deterministic summarization fallback via sumy LexRank (no LLM required) and accurate token counting via tiktoken when available

Supersedes #4033 — cleaner commit history, same feature set.

What's included

  • ContextEngine ABC (agent/context_engine.py) — plugin interface with config-driven selection
  • LCM plugin (plugins/context_engine/lcm/) — full implementation: engine, store, DAG, tokens, escalation, session persistence, primer, tools
  • DAM layer — encoder, network, persistence, retrieval, schemas, tools
  • HRR layer — holographic store, retrieval, schemas, tools
  • 30+ lifecycle tests — ingest, compact, search, expand, pin, forget
  • Plugin wiringrun_agent.py and hermes_cli/ updated for context engine plugin discovery

Test plan

  • pytest tests/plugins/context_engine/ — all LCM unit tests pass
  • pytest tests/plugins/test_dam_*.py — DAM layer tests pass
  • pytest tests/agent/test_context_engine.py — ABC contract tests pass
  • Verify no regressions in existing test suite

@dusterbloom dusterbloom force-pushed the feat/lcm-as-plugin branch 4 times, most recently from dd40fd1 to 04b1274 Compare April 13, 2026 20:35
@dusterbloom dusterbloom force-pushed the feat/lcm-as-plugin branch 2 times, most recently from 953119b to 894d08e Compare April 22, 2026 11:50
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/plugins Plugin system and bundled plugins labels Apr 22, 2026
@dusterbloom dusterbloom force-pushed the feat/lcm-as-plugin branch 2 times, most recently from 1b26e48 to 7c23959 Compare May 13, 2026 15:57
stephenschoettler and others added 16 commits May 14, 2026 23:00
- Inject engine tool schemas into agent tool surface after compressor init
- Call on_session_start() with session_id, hermes_home, platform, model
- Dispatch engine tool calls (lcm_grep, etc.) before regular tool handler
- 55/55 tests pass
Three-layer memory hierarchy (L1 Hot / L2 Warm DAM / L3 Cold HRR) as a
self-contained plugin under plugins/context_engine/lcm/.

Implements the ContextEngine ABC from PR NousResearch#6126 -- zero changes to run_agent.py.
Activated by setting context.engine: lcm in config.yaml.

Features:
  - ImmutableStore: append-only archive of all messages
  - SummaryDAG: reversible compaction with expansion
  - Dense Associative Memory (L2): Modern Hopfield network
  - HRR persistent knowledge store (L3): cross-session facts
  - LLM escalation: structured summary generation
  - 7 agent-facing tools: expand, pin, forget, search, budget, toc, focus
  - Session persistence and rebuild

Tests: 199 tests (171 internal + 28 ABC contract), all passing.
Config: lcm section in DEFAULT_CONFIG, context.engine selection.

Based on original PR NousResearch#4033, restructured as a plugin per the ContextEngine
ABC design in PR NousResearch#6126 by @teknium1 / @stephenschoettler.
The engine's active list is empty before the first compress() call
(messages are only ingested during compress()), so checking
active_tokens() created a chicken-and-egg where compression never
triggered.

Now should_compress() uses the real prompt_tokens from the API response
when available, falling back to engine state after the first compress().
Same fix applied to should_compress_preflight().
When no LLM provider is available (connection error, no API keys),
compaction now falls back to extractive summarization instead of
returning None and skipping compaction entirely.

Three-layer fallback:
1. call_llm — full LLM summarization via auxiliary provider chain
2. sumy LexRank — graph-based sentence centrality ranking (soft dep)
3. _simple_extract — zero-dependency role-based section extraction

sumy is an optional dependency (ImportError gracefully caught).
All 175 context engine tests passing.
… forget

Full lifecycle test covering:
- Ingestion into ImmutableStore and active list
- Threshold checking (NONE / ASYNC / BLOCKING)
- Auto-compaction with DAG node creation
- Lossless keyword search after compaction
- Expand restores original messages from DAG
- Pinned messages survive compaction
- Forget compacts specific messages (preserves in store)
- Store grows monotonically (lossless guarantee)
- Tool handlers (budget, toc, search, focus, pin)
- Chicken-and-egg regression tests (3 tests)
- Deterministic fallback tests (5 tests)
… import + honcho peer_name in memory tests, add user_id to session hygiene test, improve threading race in approve/deny test
…ne plugin

Implements the RLM paradigm as a ContextEngine plugin. Instead of
compressing messages, the RLM treats the prompt as an external Python
variable in a REPL environment, allowing the LLM to programmatically
peek, grep, chunk, and recursively call sub-LLMs over the context.

Changes:
- Add plugins/context_engine/rlm/__init__.py with RLMContextEngine
  and RLMAgentEnvironment classes
- Add 3 RLM tool schemas: rlm_peek, rlm_grep, rlm_partition
- Add 37 comprehensive tests in tests/plugins/context_engine/test_rlm_plugin.py
- All 212 context engine tests pass (175 LCM + 37 RLM)
…ssing

Major changes:
- RLM now works as a companion to LCM (not a replacement)
- CompositeContextEngine wraps both engines, combining all 10 tools
- Added context.rlm config key to enable RLM companion mode
- Added refresh_context() to keep REPL context fresh before each API call
- Fixed rlm_config kwargs passing throughout the loading pipeline
- Added build_context_from_messages() and refresh_context() to RLM engine

Config usage:
  context:
    engine: lcm
    rlm: true           # Enable RLM companion mode
    rlm_config:
      max_iterations: 20
      output_limit: 8192

Changes to:
- plugins/context_engine/__init__.py: Added load_composite_engine()
- plugins/context_engine/rlm/__init__.py: Added CompositeContextEngine,
  refresh_context(), build_context_from_messages(), export CompositeContextEngine
- tests/plugins/context_engine/test_rlm_plugin.py: Added composite engine tests,
  build_context_from_messages tests, refresh_context tests (47 total)
- run_agent.py: Added composite engine loading logic, refresh_context call
- tests/gateway/test_session_hygiene.py: Fix duplicate user_id kwarg
…fresh loop

Changes:
- run_agent.py: Added composite engine loading, refresh_context call before
  each API call, proper rlm_config kwargs passing
- tests: Added 47 comprehensive tests (up from 37) covering:
  * CompositeContextEngine (name, delegation, tools, dispatch, refresh)
  * build_context_from_messages (simple, structured, empty)
  * refresh_context updates REPL namespace
New config keys:
- context.rlm: Enable RLM companion mode (when engine is 'lcm')
- context.rlm_config: RLM-specific settings (max_iterations, output_limit)

Updated documentation for context.engine to list all options:
compressor | lcm | rlm
…ss signatures

ContextCompressor.compress() accepts focus_topic, but the base ContextEngine
and the Composite/Lcm plugin implementations did not. When delegation routed
through CompositeContextEngine, callers passing focus_topic= crashed with
TypeError. Add the parameter to the base signature and both plugin
implementations (Lcm accepts and ignores; Composite forwards to the wrapped
compression engine).
- test_concurrent_interrupt: add _apply_pending_steer_to_tool_results no-op
  stub on the fake agent (real method exists on AIAgent)
- test_plugin_context_engine_init: patch hermes_cli.plugins.get_plugin_context_engine
  instead of the stale plugins.context_engine.load_context_engine path
- test_run_agent: tool-call name is now assigned (not concatenated) per chunk;
  update streaming test to send full name once and empty name on later chunks
- test_browser_camofox_state: bump expected _config_version from 18 to 19
…ssmethod

Previous code called self._engine.rebuild_from_session(hermes_home, session_id)
which passed wrong positional args, discarded the returned engine, and swallowed
the exception silently. Now loads session_data from the JSON file on disk,
calls LcmEngine.rebuild_from_session(session_data, config, ...) and assigns the
returned engine to self._engine. Errors are logged at WARNING level.
on_session_start() only created _rlm_env when rlm_context was passed in kwargs,
which never happens in normal agent flow. refresh_context() only updated an
existing env, so rlm_peek/rlm_grep/rlm_partition always returned 'No context
loaded'. Now refresh_context() calls _init_rlm_env() on first call when the env
is still None, bootstrapping it from the current messages.
_load_engine_from_dir() called mod.register(collector) with no kwargs,
dropping any config passed to load_context_engine(). The fallback class-
instantiation path correctly forwarded **kwargs. Now register() also receives
**kwargs. The lcm and rlm register() functions accept and forward kwargs to
their engine constructors. The existing test asserting _rlm_env is None after
refresh_context is updated to reflect the now-correct behavior.
The hardcoded default '~/.hermes/memory_store.db' ignored the HERMES_HOME
env var and any profile customization. Now defaults to None and resolves
via get_hermes_home() at runtime, falling back to ~/.hermes only when
hermes_constants is unavailable. Explicit string/Path arguments still work
unchanged.
The compress() method called auto_compact() for any non-NONE action,
running background-intended compaction synchronously on the API call path.
Now ASYNC dispatches engine.async_compact() (runs in a daemon thread) and
BLOCKING calls auto_compact() with the compression_count increment.
The ASYNC path intentionally omits the compression_count bump because the
work happens in a background thread.
When on_session_start() rebuilt the engine from a saved session, the
ingested cursor was set to len(self._engine.active) — the post-compaction
active list length.  Since compress() computes new_count as
len(messages) - self._ingested_count, this caused two bugs:

1. After session rotation with the same input transcript, the cursor
   pointed deep below the already-ingested set, re-ingesting messages
   that were already stored.
2. On the no-saved-file path the cursor stayed at the pre-compression
   value while messages was the new shorter post-compaction list, so
   new_count went negative and ingestion silently stopped — LCM would
   never learn anything past the first compaction in a long session.

Fix: rebase _ingested_count to the number of input messages originally
fed into this engine instance, recovered from session_data['lcm']
['original_messages'].  On the no-save and exception paths, reset to 0
since nothing has been ingested in this engine instance yet.

Adds 4 regression tests covering rebuild, no-save, idempotency on the
same transcript, and incremental ingest after rebuild.
rebuild_from_session() previously assumed the active raw messages were a
contiguous suffix of the immutable store, computing the next id as
len(store) - raw_in_active.  This fails after lcm_focus / expand_summary
pull older raw messages back into the active list — those messages would
get reassigned ids on resume, so subsequent lcm_pin / lcm_forget /
lcm_expand calls would operate on the wrong store rows.

Fix: persist the original msg_id on each raw active entry as
_lcm_raw_id during serialization (new active_messages_for_session()
method), and prefer that key on rebuild.  The suffix-offset formula is
kept as a fallback for sessions saved before this change, so resume
remains compatible with old session JSON.

Adds 4 regression tests covering: msg_ids match store positions, position
preservation after focus expansion, lcm_pin operates on correct row after
rebuild, and graceful fallback when _lcm_raw_id is absent.
The unified memory tool surface (memory_search, memory_pin,
memory_expand, memory_forget, memory_reason) lives under
plugins/context_engine/lcm/hrr/ as MEMORY_TOOL_SCHEMAS and
MEMORY_TOOL_HANDLERS, but LcmContextEngine.get_tool_schemas() only
returned the 7 lcm_* schemas, and handle_tool_call() only dispatched
those names.  As a result the entire memory_* API was unreachable
from the model — dead code from the agent's perspective.

Fix: import the memory schemas / handlers at module load time (with a
guarded import so missing the HRR sub-package degrades to an empty dict
rather than failing).  get_tool_schemas() now returns lcm_* + memory_*
combined, and handle_tool_call() merges MEMORY_TOOL_HANDLERS into its
dispatch map.

Updates the lcm-abc-contract test to reflect the new schema set and
adds test_lcm_memory_tools.py covering schema exposure and dispatch.
…rmed args

_handle_partition() looped while start < len(context), advancing start by
end - overlap each iteration.  When overlap >= chunk_size, start does not
advance (or moves backwards), causing an infinite loop that hangs the agent.

Fix: validate chunk_size and overlap before entering the loop.  Return a
descriptive error JSON immediately if:
  - chunk_size is not a positive integer (covers 0 and negative values)
  - overlap is not a non-negative integer
  - overlap >= chunk_size  (the loop-hang condition)

JSON Schema for rlm_partition also tightened with minimum: 1 for chunk_size
and minimum: 0 for overlap.  The cross-field constraint (overlap < chunk_size)
cannot be expressed in JSON Schema so the runtime check is essential.

Add regression tests in tests/plugins/context_engine/test_rlm_plugin.py
(TestRlmPartitionBoundsValidation):
- overlap == chunk_size returns error, does not hang
- overlap > chunk_size returns error
- chunk_size=0 and chunk_size=-1 return error
- overlap=-1 returns error
- valid inputs (chunk_size=1000, overlap=100) still work
- overlap=0 (no overlap) is valid
- schema declares minimum constraints
…only

The dispatch condition was:

    if _engine_name == "compressor" or _engine_name not in ("lcm", "rlm"):
        pass  # fall through to built-in compressor

This swallowed any engine name that was not "lcm" or "rlm" into the no-op
branch, so e.g. context.engine: "custom" or any future plugin engine would
silently get the built-in compressor instead of the requested engine.

Fix: simplify the guard to only skip plugin loading for "compressor":

    if _engine_name == "compressor":
        pass  # built-in — no plugin to load

All other names fall through to the generic else branch which calls
load_context_engine(_engine_name, ...).  lcm and rlm are special-cased
as before (lcm supports composite mode with _rlm_enabled; rlm is standalone).
Existing behavior for lcm, rlm, and composite path is preserved.

Add regression tests in tests/plugins/context_engine/test_engine_dispatch.py:
- custom engine name calls load_context_engine("custom", ...)
- compressor never calls load_context_engine
- lcm still calls load_context_engine (not broken by fix)
- rlm standalone calls load_context_engine("rlm", ...)
- custom engine with rlm=True tries load_composite_engine first
rebuild_from_session() returns a fresh LcmEngine with no hrr_store or
retriever set.  on_session_start() was not re-attaching them, so every
resumed session silently lost L3 cross-session memory (HRR) and L2
per-session retrieval (DAM).  Re-apply the same init pattern used in
__init__ immediately after the rebuild assignment.

Regression tests in TestHrrAndDamReattachedAfterRebuild verify both
layers are present after a save/reload cycle.
LcmConfig had an enabled field (default True) but the constructor
ignored it — the full engine was always built and all tools exposed.
Setting enabled=False now sets self._disabled=True and gates the four
user-visible entry points:

- get_tool_schemas() returns []
- compress() passes messages through unchanged
- should_compress() always returns False
- handle_tool_call() returns {"error": "LCM is disabled"}

HRR and DAM init are also skipped when disabled.

Regression tests in TestEnabledFalseDisablesEngine cover all four
paths plus a sanity check that enabled=True still exposes tools.
Legacy session files (no lcm.original_messages key) caused
rebuild_from_session() to populate the store from active messages, but
_ingested_count was left at 0.  The next compress() call therefore saw
len(messages) - 0 = N "new" messages and re-ingested the entire restored
context, doubling the store.

Fix: when original_messages is absent or empty, derive _ingested_count
from len(self._engine.store) after rebuild, which equals the number of
raw messages that were appended during reconstruction.

Regression tests in TestLegacySessionIngestedCount verify correct cursor
seeding, no duplicate ingestion, and clean handling of additional new
messages arriving after legacy resume.
_retreat_from_tool_result only handled the case where the block's last entry
was a bare tool-result message.  It missed the symmetric case: the block ends
with an assistant+tool_calls message whose matching tool result sits in the
protected tail.  Compacting that assistant away left an orphan tool message,
producing an invalid OpenAI request.

Add _has_tool_calls() static helper (OpenAI and Anthropic style) and extend
_retreat_from_tool_result to retreat when either case is detected.

Regression tests in test_compaction_boundaries.py cover both message formats,
boundary detection, and post-compact active-list invariant.
…ress

After compaction, compress() returned a shorter active list but left
_ingested_count at the original input length.  The next call computed
new_count = len(new_messages) - old_length, which was negative, silently
skipping fresh turns until the conversation re-grew past the stale cursor.

Fix: when compaction fires (ASYNC or BLOCKING), reset _ingested_count to
len(result) so the cursor tracks the output that run_agent will use as input
on the next turn.  When no compaction fires the existing assignment is correct.

Regression tests added to TestIngestedCountAfterCompaction in test_lcm_resume.py.
…llback

The Layer 3 keyword fallback (LCM store linear scan) fired unconditionally
whenever no results were found, regardless of the source parameter.  When the
caller passed source='memory' (intending cross-session HRR only), session
messages leaked into the results via the fallback.

Fix: wrap the fallback in `if source != "memory":` so it only fires for
source='auto' (default) and source='session', matching the documented contract.

Regression tests added to TestMemorySearchSourceGating in test_lcm_memory_tools.py.
…llback

The else-branch in compose() caught all non-AND operations, including 'NOT',
and silently executed OR semantics.  This made NOT queries return results that
were semantically wrong without any warning.

Fix: add explicit branches for OR and NOT; reject multi-query NOT (undefined
semantics) and any unknown operation string by returning [] with a warning log.
Single-query NOT inverts binary hidden activations (1.0 - h), which is correct
because get_hidden_activations() always returns binary float32 values.

Also add missing logger import to retrieval.py.

Regression tests in test_dam_compose.py cover NOT/AND/OR/unknown operations,
case-insensitivity, single vs multi-query NOT, and the regression check that
NOT scores differ from OR scores.
on_session_start reloaded tau_soft, tau_hard, and protect_last_n from config
but never updated _disabled.  If a user changed lcm.enabled: false in
config.yaml and the session restarted, the engine stayed enabled — compress()
continued to run and tool schemas remained exposed.

Fix: add `self._disabled = not self._config.enabled` immediately after the
config reload block, so the enabled flag takes effect for the current session.

Regression tests added to TestDisabledFlagOnConfigReload in test_lcm_config_apply.py,
covering enabled->disabled, disabled->enabled, and compress() pass-through.
After on_session_start reloads config.yaml, propagate lcm.summary_model
to the live Summarizer instance so it uses the updated model instead of
the constructor-time default.

Add regression tests in test_lcm_config_apply.py covering the happy path
and the no-overwrite guard when summary_model is absent from reloaded config.
…'t retract)

When on_session_start detects a config reload that flips lcm.enabled from
true to false, emit a WARNING telling the user that already-advertised tool
schemas cannot be retracted until the agent is restarted.  All LCM tool
calls already return {"error": "LCM is disabled"} via handle_tool_call so
behaviour is safe; this surfaces the stale-schema situation explicitly.

Add regression tests covering the happy-path warning, no-op when already
disabled, and no spurious warning on the disable→enable direction.
…s display

async_compact() schedules background work but was not incrementing
compression_count, so the status display showed zero compactions even
after async compactions fired.  Count when scheduled (same as blocking)
so the counter reflects "X compactions triggered" for both paths.

Add regression tests using a mocked check_thresholds to isolate each
branch without relying on real token counting.
@dusterbloom dusterbloom force-pushed the feat/lcm-as-plugin branch from 7c23959 to 6796bc8 Compare May 14, 2026 21:04
@dusterbloom

Copy link
Copy Markdown
Contributor Author

Closing as stale: branch has fallen behind main and is conflicting, with no active review. Will reopen with a clean rebase if the change is still wanted.

@dusterbloom dusterbloom closed this Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants