Skip to content

feat(context-engine): add request preparation hooks - #41918

Closed
johnnykor82 wants to merge 1 commit into
NousResearch:mainfrom
johnnykor82:context-engine-turn-complete-hook
Closed

feat(context-engine): add request preparation hooks#41918
johnnykor82 wants to merge 1 commit into
NousResearch:mainfrom
johnnykor82:context-engine-turn-complete-hook

Conversation

@johnnykor82

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds two optional ContextEngine hooks for memory/context-management plugins:

  • on_turn_complete(...) observes a finalized turn transcript snapshot after a user turn completes.
  • prepare_request_messages(...) can replace provider request messages for a single API call without mutating persisted conversation history.

This gives external context engines a native path for ingestion and request-only context assembly without forcing should_compress() == True just to receive message history. Existing engines keep their current behavior because both hooks default to no-op and host calls fail open.

Related/complementary work: #15498 proposes per-message/after-turn lifecycle hooks. This PR is narrower around finalized-turn observation and the pre-provider request replacement surface needed for retrieval/context assembly.

Related Issue

Related to #23837, #25115, #36765, and #29370.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • agent/context_engine.py: adds optional on_turn_complete(...) and prepare_request_messages(...) no-op methods to the ContextEngine contract.
  • agent/conversation_loop.py: calls the post-turn hook with shallow-copied transcript and usage metadata; calls the request hook before provider cache-control annotations; validates hook return values and fails open on errors.
  • tests/agent/test_context_engine.py: covers default no-op hook behavior.
  • tests/agent/test_context_engine_host_contract.py: covers metadata forwarding, copy semantics, request-only replacement, and fail-open behavior.
  • tests/run_agent/test_run_agent.py: covers full conversation-loop behavior, request-only persistence semantics, and prompt-cache ordering.

How to Test

  1. Focused companion-plugin hook checks:

    env PYTHONPATH=/Users/openclaw/.hermes/plugins/_hermes-agent-pr \
      /Users/openclaw/.hermes/hermes-agent/venv/bin/python3 -m pytest -q \
      /Users/openclaw/.hermes/plugins/_hermes-mneme-native/tests/unit/test_context_tools.py \
      /Users/openclaw/.hermes/plugins/_hermes-mneme-native/tests/unit/test_native_hooks.py

    Result: 6 passed.

  2. Same focused checks against the isolated runtime-copy plugin:

    env PYTHONPATH=/Users/openclaw/.hermes/plugins/_hermes-agent-pr \
      /Users/openclaw/.hermes/hermes-agent/venv/bin/python3 -m pytest -q \
      /Users/openclaw/.hermes/plugins/_hermes-context-hooks-test-home/plugins/hermes-mneme/tests/unit/test_context_tools.py \
      /Users/openclaw/.hermes/plugins/_hermes-context-hooks-test-home/plugins/hermes-mneme/tests/unit/test_native_hooks.py

    Result: 6 passed.

  3. Focused Hermes host-contract checks:

    env HERMES_HOME=/private/tmp/hermes-context-hooks-pytest \
      PYTHONPATH=/Users/openclaw/.hermes/plugins/_hermes-agent-pr \
      /Users/openclaw/.hermes/hermes-agent/venv/bin/python3 -m pytest -q \
      tests/agent/test_context_engine_host_contract.py \
      tests/run_agent/test_plugin_context_engine_init.py \
      tests/run_agent/test_commit_memory_session_context_engine.py \
      tests/run_agent/test_compression_boundary_hook.py

    Result: 27 passed, 1 warning (audioop deprecation from discord/player.py).

  4. Additional checks:

    git diff --check
    python3 -m py_compile agent/context_engine.py agent/conversation_loop.py

    Result: both passed.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

No UI changes.

@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 Jun 8, 2026
@johnnykor82

Copy link
Copy Markdown
Contributor Author

Small clarification on priority: this is not intended as a cosmetic plugin convenience.

The current workaround for external context engines is to return should_compress() == True so Hermes calls compress() every turn, even when no compression is desired. That mixes lifecycle observation/request assembly with compression semantics and can trigger misleading host behavior.

This PR keeps existing behavior unchanged, but adds a fail-open native path for:

  • observing finalized turn snapshots;
  • preparing request-only context before provider dispatch;
  • avoiding persisted transcript mutation;
  • letting plugins stop abusing compress() as a generic hook.

So the main value is reducing coupling and making context-engine plugins safer to implement.

Expose optional post-turn observation and pre-request message preparation hooks so context engines can ingest finalized transcript snapshots and assemble request-only context without invoking host compression semantics.
@johnnykor82
johnnykor82 force-pushed the context-engine-turn-complete-hook branch from 093f764 to 71eb208 Compare June 9, 2026 10:58
@johnnykor82

Copy link
Copy Markdown
Contributor Author

Looks ready from my side. The PR is currently blocked because required checks have not reported on the fork branch. Could a maintainer approve/run the workflows when convenient?

@chaos-xxl

Copy link
Copy Markdown
Contributor

RFC author of #36765 here — endorsing this direction.

This is the selection-vs-compression split I argued for, landed as a concrete,
additive diff. Three things worth putting on record for a maintainer weighing it:

  1. It's derived from the proposed shape, not just adjacent.
    prepare_request_messages(..., *, incoming_message=None, budget_tokens=0) -> List | None with None as fall-through is the signature I sketched in
    [RFC] Treat "context selection/routing" as a first-class ContextEngine concern, distinct from compression #36765 §6 — and the request-only / don't-mutate-persisted-transcript boundary
    is actually cleaner than my draft, which left that split implicit.

  2. It fixes a correctness issue, not just ergonomics. In my own dogfooding,
    the should_compress=True backdoor meant that when my engine's backend went
    down, the built-in compressor still engaged at its default threshold —
    aggressive compaction of a session nowhere near budget. Installing the engine
    and having it fail was worse than not installing it at all (the User message arriving during preflight compaction aborts the agent loop when an alt context engine is active #29370
    shape). A no-op-default, fail-open hook removes that class of bug at the
    source — which is why I'd argue P3 undersells it.

  3. It collapses a cluster, not a one-off. [Feature] ContextEngine: per-turn message observation hook (currently requires abusing compress() as a backdoor) #23837, Allow alternative context engines to suppress or customize preflight compression status #25115, feat(context_engine): ingest_message + after_turn lifecycle hooks #15498, User message arriving during preflight compaction aborts the agent loop when an alt context engine is active #29370 are
    the same missing abstraction patched different ways; this is the shared
    mechanism.

I have no stake in the naming or the exact param list — happy to defer to
maintainer conventions. Flagging mainly that, from the issue side, this is the
right primitive and it's blocked on CI/review rather than on design. Glad to
test it against my own selection-style engine if that's useful.

@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 supplying a narrow host contract and focused coverage. The requested hooks are still absent on current main, but one fail-open path needs tightening before salvage.

Problems

  • agent/conversation_loop.py:246-252 accepts any list replacement. The new normalizer at agent/conversation_loop.py:260 then calls .get() on each member, so a plugin return such as ["bad"] raises instead of preserving the original request. Validate all members as message dicts (and reject an empty replacement) before accepting the result.
  • This is a public ContextEngine API addition, but website/docs/developer-guide/context-engine-plugin.md:93-101 and :148-157 still document the old optional-method and lifecycle contracts. The PR changes no documentation file.

Suggested changes

  • Add malformed-return regression tests alongside the existing fail-open test: non-list, empty list, and a list containing a non-dict must each keep the original request.
  • Document on_turn_complete and prepare_request_messages, including the request-only persistence boundary.

Current main's request and finalization paths have moved (agent/conversation_loop.py:792-955, agent/turn_finalizer.py:30-46), so the DIRTY PR should be manually salvaged into those paths rather than applied mechanically.

Automated hermes-sweeper review.


if replacement is None:
return request_messages, False
if not isinstance(replacement, list):

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.

A list alone is not a safe replacement contract: ["bad"] passes this check, then _normalize_request_messages_for_api() calls .get() on the string and the request fails instead of failing open. Require a non-empty list whose members are message dicts (at minimum), otherwise log and return request_messages.

@teknium1 teknium1 added the sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state label Jul 14, 2026
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades 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 14, 2026
chaos-xxl added a commit to chaos-xxl/hermes-agent that referenced this pull request Jul 21, 2026
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
chaos-xxl added a commit to chaos-xxl/hermes-agent that referenced this pull request Jul 21, 2026
Adds the post-turn observation verb as the companion to select_context():
an optional, no-op-default on_turn_complete() called once after the
assistant/tool loop finishes, with the finalized transcript snapshot. Lets
an engine ingest/index/summarize the completed turn to inform the next
select_context(). Wired via _notify_context_engine_turn_complete() from
turn_finalizer.finalize_turn(); fail-open, base no-op short-circuited so
non-implementing engines (incl. the built-in compressor) pay nothing.

This is the request-assembly + observation pair from NousResearch#41918; with this
commit the PR fully subsumes NousResearch#41918's two hooks (prepare_request_messages
-> select_context, on_turn_complete) rather than only the selection half.

Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
chaos-xxl added a commit to chaos-xxl/hermes-agent that referenced this pull request Jul 21, 2026
The on_turn_complete() observation hook is the engine's post-turn signal,
so it should receive the completed turn's canonical token usage when the
host has it, not a hardcoded None. Per @johnnykor82's NousResearch#41918 contract: the
engine uses prompt/completion + cache_read/write/reasoning buckets to judge
how large/expensive the selected context was before the next select_context().

- conversation_loop.py: stash the most recent provider response's usage_dict
  (the same canonical shape fed to update_from_response) on the agent as
  _last_turn_usage; reset to None at turn start so turns that never reach a
  provider response (early failure / interrupt) forward None, not a stale
  prior turn's usage.
- turn_finalizer.py: forward agent._last_turn_usage instead of usage=None.
- context_engine.py: document the usage param contract on the ABC hook.
- tests: cover both ends through the real finalize_turn path — completed turn
  forwards the full canonical bucket set intact; no-response turn forwards None.

Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
chaos-xxl added a commit to chaos-xxl/hermes-agent that referenced this pull request Jul 21, 2026
… public hooks

- _apply_context_engine_selection: reject an empty list. all([]) is True, so
  a [] returned by a failing/buggy engine previously replaced a valid request
  with an empty message list the downstream sanitizers can't restore; now it
  falls open to the unmodified request (honors the fail-open contract).
  Thanks @johnnykor82 for catching this on NousResearch#41918's review.
- test: empty list keeps the original request (fail-open regression).
- docs: document select_context()/on_turn_complete() in the public
  context-engine plugin guide (were still describing only the old contract).
teknium1 pushed a commit that referenced this pull request Jul 24, 2026
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
teknium1 pushed a commit that referenced this pull request Jul 24, 2026
Adds the post-turn observation verb as the companion to select_context():
an optional, no-op-default on_turn_complete() called once after the
assistant/tool loop finishes, with the finalized transcript snapshot. Lets
an engine ingest/index/summarize the completed turn to inform the next
select_context(). Wired via _notify_context_engine_turn_complete() from
turn_finalizer.finalize_turn(); fail-open, base no-op short-circuited so
non-implementing engines (incl. the built-in compressor) pay nothing.

This is the request-assembly + observation pair from #41918; with this
commit the PR fully subsumes #41918's two hooks (prepare_request_messages
-> select_context, on_turn_complete) rather than only the selection half.

Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
teknium1 pushed a commit that referenced this pull request Jul 24, 2026
The on_turn_complete() observation hook is the engine's post-turn signal,
so it should receive the completed turn's canonical token usage when the
host has it, not a hardcoded None. Per @johnnykor82's #41918 contract: the
engine uses prompt/completion + cache_read/write/reasoning buckets to judge
how large/expensive the selected context was before the next select_context().

- conversation_loop.py: stash the most recent provider response's usage_dict
  (the same canonical shape fed to update_from_response) on the agent as
  _last_turn_usage; reset to None at turn start so turns that never reach a
  provider response (early failure / interrupt) forward None, not a stale
  prior turn's usage.
- turn_finalizer.py: forward agent._last_turn_usage instead of usage=None.
- context_engine.py: document the usage param contract on the ABC hook.
- tests: cover both ends through the real finalize_turn path — completed turn
  forwards the full canonical bucket set intact; no-response turn forwards None.

Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
teknium1 pushed a commit that referenced this pull request Jul 24, 2026
… public hooks

- _apply_context_engine_selection: reject an empty list. all([]) is True, so
  a [] returned by a failing/buggy engine previously replaced a valid request
  with an empty message list the downstream sanitizers can't restore; now it
  falls open to the unmodified request (honors the fail-open contract).
  Thanks @johnnykor82 for catching this on #41918's review.
- test: empty list keeps the original request (fail-open regression).
- docs: document select_context()/on_turn_complete() in the public
  context-engine plugin guide (were still describing only the old contract).
@teknium1

Copy link
Copy Markdown
Contributor

The request-preparation + turn-observation surface this PR proposed has landed via salvage PR #70458 (from #51226, the RFC #36765 consolidation): select_context() covers your prepare_request_messages() and on_turn_complete() landed following YOUR design — with the real canonical usage forwarded (the contract detail you insisted on), and your Co-authored-by preserved in the merged commits.

Beyond the design, your review on #51226 caught two real defects (the usage=None forwarding gap and the all([]) empty-list fail-open hole) — both fixed with regression tests before merge. Thanks @johnnykor82; closing as consolidated-and-landed with your authorship in history.

@teknium1 teknium1 closed this Jul 24, 2026
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
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
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Adds the post-turn observation verb as the companion to select_context():
an optional, no-op-default on_turn_complete() called once after the
assistant/tool loop finishes, with the finalized transcript snapshot. Lets
an engine ingest/index/summarize the completed turn to inform the next
select_context(). Wired via _notify_context_engine_turn_complete() from
turn_finalizer.finalize_turn(); fail-open, base no-op short-circuited so
non-implementing engines (incl. the built-in compressor) pay nothing.

This is the request-assembly + observation pair from NousResearch#41918; with this
commit the PR fully subsumes NousResearch#41918's two hooks (prepare_request_messages
-> select_context, on_turn_complete) rather than only the selection half.

Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
The on_turn_complete() observation hook is the engine's post-turn signal,
so it should receive the completed turn's canonical token usage when the
host has it, not a hardcoded None. Per @johnnykor82's NousResearch#41918 contract: the
engine uses prompt/completion + cache_read/write/reasoning buckets to judge
how large/expensive the selected context was before the next select_context().

- conversation_loop.py: stash the most recent provider response's usage_dict
  (the same canonical shape fed to update_from_response) on the agent as
  _last_turn_usage; reset to None at turn start so turns that never reach a
  provider response (early failure / interrupt) forward None, not a stale
  prior turn's usage.
- turn_finalizer.py: forward agent._last_turn_usage instead of usage=None.
- context_engine.py: document the usage param contract on the ABC hook.
- tests: cover both ends through the real finalize_turn path — completed turn
  forwards the full canonical bucket set intact; no-response turn forwards None.

Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
… public hooks

- _apply_context_engine_selection: reject an empty list. all([]) is True, so
  a [] returned by a failing/buggy engine previously replaced a valid request
  with an empty message list the downstream sanitizers can't restore; now it
  falls open to the unmodified request (honors the fail-open contract).
  Thanks @johnnykor82 for catching this on NousResearch#41918's review.
- test: empty list keeps the original request (fail-open regression).
- docs: document select_context()/on_turn_complete() in the public
  context-engine plugin guide (were still describing only the old contract).
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 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-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/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants