feat(context-engine): add preassemble() hook for per-turn rewrite - #24949
feat(context-engine): add preassemble() hook for per-turn rewrite#24949100yenadmin wants to merge 1 commit into
Conversation
Adds an additive, non-breaking ABC method `ContextEngine.preassemble( messages, budget_tokens=None) -> list[Message]` (default no-op) with a single call site in `run_agent.py` right after sanitization and before the retry loop. Lets pluggable context engines (LCM, etc.) substitute the message list every turn — not just on overflow — without breaking session lineage or rotating the SQLite session ID. ## Why Today `ContextEngine.compress()` only fires when `should_compress()` returns True (overflow). Plugin engines that maintain a lossless conversation pyramid (raw + summaries DAG) need to substitute evicted raw turns with summary stubs on EVERY prompt build to keep the prompt under budget while preserving long-range recall — not just at the overflow boundary. The `pre_llm_call` hook can't do this: it concatenates plugin output into the current user message and never rewrites the message list (intentional, to preserve prompt cache prefix). The new `preassemble()` hook gives engines explicit, structured per-turn rewrite capability while keeping `pre_llm_call` semantics unchanged. ## What - `agent/context_engine.py`: add `preassemble()` method to the ABC. Default returns `messages` unchanged. - `run_agent.py`: call `self.context_compressor.preassemble(api_messages, budget_tokens=threshold_tokens)` after surrogate sanitization and before the retry loop. Exception-safe — failures log a warning and fall back to unmodified messages. - `tests/agent/test_context_engine.py`: add 3 tests covering default no-op behavior (with and without `budget_tokens`) and a subclass-override case where preassemble substitutes a stub for middle turns while preserving the system message at index 0 and the final user message. ## Impact - **Additive only.** `ContextCompressor` (the default engine) keeps the no-op default; zero behavior change for non-plugin users. - **Test suite**: 22/22 pass (3 new + 19 existing). - **Blast radius**: GitNexus impact analysis on `ContextEngine` shows LOW risk; 4 direct callers (ContextCompressor, hermes_cli/plugins.py, agent/context_compressor.py, plugins/context_engine/__init__.py); 0 affected processes. ## Context Drafted as part of [electricsheephq/lossless-hermes](https://github.com/electricsheephq/lossless-hermes) — a community port of Lossless Claw to Python/Hermes. Related upstream work: NousResearch#22929 (Wire `on_pre_compress` into the context compression pipeline). This patch is independent of and complementary to that issue: `on_pre_compress` would fire at compress-time, `preassemble` fires at every API call. Fallback path documented if this isn't merged: https://github.com/electricsheephq/lossless-hermes/blob/main/docs/adr/010-always-on-assembly.md
Lands the four Wave-0 deliverables described in ROADMAP.md before Wave 1 (Epic 00 Scaffolding) dispatches the first Issue Executor agent. ## State files (operational backbone) - `STATUS.md` — cached projection of wave/milestone state. Git wins on drift. - `BLOCKERS.md` — append-only decision queue. - `LEDGER.md` — wave cost + velocity ledger. - `docs/upstream/` — 4 patch tracking files (one per ADR-015 patch). These three plus the upstream/ directory implement ADR-024's operational backbone for AI-driven execution. Resumability protocol now in STATUS.md. ## Schema-diff CI scaffold (closes ADR-025 open Q#2) - `scripts/schema_diff.sh` — orchestrator with three modes (`--refresh-reference`, `--verify`, `--check-reference`). - `scripts/extract_lcm_schema.ts` — Node helper that runs `runLcmMigrations()` from `Martian-Engineering/lossless-claw` at commit 1f07fbd and dumps schema via `sqlite_master`. - `scripts/extract_python_schema.py` — Python counterpart that runs `lossless_hermes.db.migration.run_lcm_migrations` (returns exit 2 with a clear "not yet implemented" message pre-Wave-2; will activate when Epic 01 lands). - `tests/fixtures/lcm_reference_schema.sql` — golden reference with 92 schema objects extracted from LCM 1f07fbd. Used by `--verify` as the byte-compat target. Re-extracted by `--refresh-reference` when the LCM pin bumps. - `tests/fixtures/lcm_reference_meta.txt` — provenance metadata. - `.github/workflows/schema-diff.yml` — CI runs `--verify` on every PR touching `db/migration*` paths, plus a weekly cron + manual-dispatch `--check-reference` job that re-extracts TS schema and catches a stale committed reference. The scaffold is validated end-to-end: `pnpm install` in lossless-claw succeeded, the extract script produced a 92-object reference, and the fixture is committed. Wave 2 first issue will be the first time `--verify` runs against real Python migrations. ## Upstream Hermes preassemble() PR (closes ADR-010 R01) Filed NousResearch/hermes-agent#24949 — adds an additive `ContextEngine.preassemble(messages, budget_tokens=None)` ABC method plus a single call site in `run_agent.py` (post-sanitization, pre-retry-loop). 22/22 tests pass (3 new + 19 existing). Updated `docs/upstream/001-preassemble-abc.md`: - `status: drafted` → `filed` - `pr_url: https://github.com/NousResearch/hermes-agent/pull/24949` - Transition log entry with diff stats and impact analysis. Fallback path (ADR-010 Option A) remains documented if the PR is rejected; we don't block lossless-hermes execution on the upstream merge. ## Wave 0 exit gate status - [x] Schema-diff CI scaffold green on empty schema ✅ - [x] Reference extracted (92 objects, commit 1f07fbd) ✅ - [x] Upstream PR filed (#24949) ✅ - [x] State files committed ✅ - [x] LCM index responding (openclaw-code-index MCP server, repo `lossless-claw`, 7382 nodes, 6836 embeddings, file:line refs work) ✅ - [ ] Dry-run on 00-01 (Issue Executor + Pair Reviewer end-to-end) — next. ## Next Dispatch first Issue Executor agent on `epics/00-scaffolding/issues/00-01-pyproject-and-package-skeleton.md`, followed by a Pair Reviewer on the resulting PR. If both produce shippable work, Wave 0 gate closes and Wave 1 fan-out begins.
|
Thanks for the focused ContextEngine extension. The need is still present on current main, but this implementation needs a redesigned salvage rather than a direct transplant. Problems
Suggested changes
This is an automated hermes-sweeper review. |
|
Closing in favor of #51226 / RFC #36765. The The review's other points stand too: a per-request rewrite hook needs a defined prompt-cache contract before becoming host API, and #51226 is the right venue for that conversation. Thanks @teknium1 for the thorough salvage analysis. |
Adds an optional, no-op-default select_context() hook to the ContextEngine ABC, called every turn after the request messages are assembled and before provider dispatch — independent of should_compress(). Lets an engine select or replace which context enters the prompt for a single request (retrieval, topic routing, role/branch switching) without mutating persisted history, removing the need to abuse should_compress()=True as a per-turn callback. The host call site (_apply_context_engine_selection) is fail-open: a missing hook, an exception, or an invalid return value leaves the assembled request untouched. Additive and non-breaking: the built-in compressor and every existing engine are unaffected. Consolidates the per-turn request-assembly surface proposed across NousResearch#41918, NousResearch#24949, NousResearch#47109, and NousResearch#50053 into one canonical hook (RFC NousResearch#36765). Related: NousResearch#36765 NousResearch#41918 NousResearch#24949 NousResearch#47109 NousResearch#50053 NousResearch#23837 NousResearch#25115 NousResearch#29370
Adds an optional, no-op-default select_context() hook to the ContextEngine ABC, called every turn after the request messages are assembled and before provider dispatch — independent of should_compress(). Lets an engine select or replace which context enters the prompt for a single request (retrieval, topic routing, role/branch switching) without mutating persisted history, removing the need to abuse should_compress()=True as a per-turn callback. The host call site (_apply_context_engine_selection) is fail-open: a missing hook, an exception, or an invalid return value leaves the assembled request untouched. Additive and non-breaking: the built-in compressor and every existing engine are unaffected. Consolidates the per-turn request-assembly surface proposed across #41918, Related: #36765 #41918 #24949 #47109 #50053 #23837 #25115 #29370
Adds an optional, no-op-default select_context() hook to the ContextEngine ABC, called every turn after the request messages are assembled and before provider dispatch — independent of should_compress(). Lets an engine select or replace which context enters the prompt for a single request (retrieval, topic routing, role/branch switching) without mutating persisted history, removing the need to abuse should_compress()=True as a per-turn callback. The host call site (_apply_context_engine_selection) is fail-open: a missing hook, an exception, or an invalid return value leaves the assembled request untouched. Additive and non-breaking: the built-in compressor and every existing engine are unaffected. Consolidates the per-turn request-assembly surface proposed across NousResearch#41918, Related: NousResearch#36765 NousResearch#41918 NousResearch#24949 NousResearch#47109 NousResearch#50053 NousResearch#23837 NousResearch#25115 NousResearch#29370
Summary
Adds an additive, non-breaking ABC method
ContextEngine.preassemble(messages, budget_tokens=None) -> list[Message](default no-op) with a single call site inrun_agent.pyright after sanitization and before the retry loop. Lets pluggable context engines (LCM-style, etc.) substitute the message list every turn — not just on overflow — without breaking session lineage or rotating the SQLite session ID.Diff: +110 / 0, 3 files. 22/22 tests pass.
Why
Today
ContextEngine.compress()only fires whenshould_compress()returns True (overflow). Plugin engines that maintain a lossless conversation pyramid (raw + summaries DAG) need to substitute evicted raw turns with summary stubs on every prompt build to keep the prompt under budget while preserving long-range recall — not just at the overflow boundary.The existing
pre_llm_callhook can't do this: it concatenates plugin output into the current user message and never rewrites the message list (intentional, to preserve the prompt cache prefix). The newpreassemble()hook gives engines explicit, structured per-turn rewrite capability while keepingpre_llm_callsemantics unchanged.This is independent of and complementary to #22929 (Wire `on_pre_compress` into the context compression pipeline): `on_pre_compress` would fire at compress-time; `preassemble` fires at every API call.
What
API design
```python
def preassemble(
self,
messages: List[Dict[str, Any]],
budget_tokens: int = None,
) -> List[Dict[str, Any]]:
"""Per-turn rewrite hook called immediately before each model API request.
```
System message at index 0 (if present) is documented as the engine's responsibility to preserve.
Impact
Context
Drafted as part of electricsheephq/lossless-hermes — a community port of Lossless Claw to Python/Hermes. Full design rationale and the ADR-documented fallback (if this isn't merged) live at docs/adr/010-always-on-assembly.md. The fallback is workable but breaks session lineage (forces `should_compress=True` every turn → SQLite session ID rotation → memory-provider lineage warnings). This patch eliminates that compromise.
The docstring at `agent/context_engine.py:5` already names LCM as a planned tenant of the plugin slot, so this lands on the surface the ABC was designed to support.
Test plan
Reversibility
100% reversible: revert the 3-file commit. Zero schema changes, zero external contract changes, zero new dependencies.