feat(lcm): Lossless Context Management as ContextEngine plugin#6464
Closed
dusterbloom wants to merge 41 commits into
Closed
feat(lcm): Lossless Context Management as ContextEngine plugin#6464dusterbloom wants to merge 41 commits into
dusterbloom wants to merge 41 commits into
Conversation
dd40fd1 to
04b1274
Compare
953119b to
894d08e
Compare
4 tasks
894d08e to
0139573
Compare
1b26e48 to
7c23959
Compare
- 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.
…uppresses tools at registration
…rror on custom engines
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.
7c23959 to
6796bc8
Compare
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ContextEngine, replacing the monolithicContextCompressorwith a DAG-based message store that supports compaction, expansion, pinning, and forgettingSupersedes #4033 — cleaner commit history, same feature set.
What's included
agent/context_engine.py) — plugin interface with config-driven selectionplugins/context_engine/lcm/) — full implementation: engine, store, DAG, tokens, escalation, session persistence, primer, toolsrun_agent.pyandhermes_cli/updated for context engine plugin discoveryTest plan
pytest tests/plugins/context_engine/— all LCM unit tests passpytest tests/plugins/test_dam_*.py— DAM layer tests passpytest tests/agent/test_context_engine.py— ABC contract tests pass