Skip to content

feat(context-engine): lifecycle hooks - turn observation, request ass… - #47109

Closed
huangxun375-stack wants to merge 1 commit into
NousResearch:mainfrom
huangxun375-stack:feat/context-engine-hooks
Closed

feat(context-engine): lifecycle hooks - turn observation, request ass…#47109
huangxun375-stack wants to merge 1 commit into
NousResearch:mainfrom
huangxun375-stack:feat/context-engine-hooks

Conversation

@huangxun375-stack

@huangxun375-stack huangxun375-stack commented Jun 16, 2026

Copy link
Copy Markdown

What does this PR do?

This PR adds optional, capability-gated lifecycle hooks to ContextEngine so external context engines can observe completed turns, assemble provider-bound request messages, and snapshot the full transcript before compression without abusing compress() as a generic backdoor.

Today ContextEngine mainly models compression through should_compress() / compress(). That makes per-turn observation or pre-LLM context assembly difficult to express cleanly, and pushes engines toward treating request-time transforms as compaction events. This PR separates those concerns while preserving the existing built-in compressor behavior.

This is intentionally separate from MemoryProvider. MemoryProvider remains the right abstraction for simple long-term memory integrations: it can sync completed turns and inject recalled memory text. However, it cannot replace, trim, reorder, or rebuild the full provider-bound message list. Context engines need a different seam for systems that manage the whole outbound context window rather than only appending recalled memory.

The new hooks are:

  • on_turn_complete(messages, TurnInfo): finalized-turn observation in finalize_turn for ingest/index/memory workflows.
  • prepare_request_messages(messages, RequestContext) -> list | None: outbound-only request assembly before provider dispatch; None means passthrough; returned views are never written back to the canonical transcript.
  • on_pre_compress(messages): lossless snapshot opportunity at the single compression chokepoint before compaction mutates the window.

Hosts gate every call on a ContextEngineCapabilities snapshot taken once at engine registration. Hook dispatch is fail-open through engine_hook(): failures are logged and degrade to the original behavior. Review-fork agents are isolated from lifecycle side effects.

Related Issue

Related to #23837, #24949, #36765, and #41918.

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 TurnInfo, RequestContext, ContextEngineCapabilities, and engine_hook().
    • Adds optional on_turn_complete, prepare_request_messages, and on_pre_compress hooks.
  • agent/conversation_loop.py
    • Calls prepare_request_messages before building provider-bound API messages.
    • Keeps the assembled view request-only and out of persisted transcript state.
    • Hands memory prefetch and plugin pre_llm_call context to the engine when it takes over assembly, avoiding double injection.
  • agent/turn_finalizer.py
    • Calls on_turn_complete before external memory sync.
  • run_agent.py
    • Calls on_pre_compress at the compression chokepoint before compress() mutates the message window.
  • agent/agent_init.py and agent/background_review.py
    • Snapshot engine capabilities and disable lifecycle hooks for internal review forks.
  • Adds contract, integration, loop-path, and real run_conversation E2E tests.

How to Test

  1. Run the lifecycle hook suites:
scripts/run_tests.sh tests/agent/test_context_engine_lifecycle.py \
  tests/agent/test_context_engine_lifecycle_integration.py \
  tests/run_agent/test_context_engine_lifecycle_e2e.py \
  tests/run_agent/test_context_engine_loop_paths.py
  1. Verify existing engines that do not declare capabilities do not receive new hook calls.
  2. Verify request-assembly views are provider-bound only and do not mutate the canonical transcript.

Targeted lifecycle suite result:

41 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

Documentation & Housekeeping

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

Screenshots / Logs

41 passed

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have duplicate This issue or pull request already exists labels Jun 16, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Duplicate of #15498 — both PRs implement the same ContextEngine lifecycle hooks feature (per-turn observation + request assembly), tracking feature request #23837. #15498 (after_turn / ingest_message hooks) is the earlier open PR. Marking this as a competing duplicate; maintainers can choose between the two approaches.

…embly, lossless pre-compress snapshot, capability gating

ContextEngine gains three optional lifecycle hooks (all no-op defaults,
zero behavior change for the built-in compressor and existing engines):

- on_turn_complete(messages, TurnInfo): finalized-turn observation in
  finalize_turn (ingest/index/memory without abusing compress())
- prepare_request_messages(messages, RequestContext) -> list|None:
  outbound-only request assembly before prompt caching; None = passthrough
  (prompt-cache prefix stays byte-stable); never written back to the
  canonical transcript
- on_pre_compress(messages): lossless snapshot at the single compression
  chokepoint before compaction mutates the window

Hosts gate every call on a ContextEngineCapabilities snapshot taken once
at engine registration (capabilities() declared by the engine, fail-open
to all-False defaults). engine_hook() wraps every dispatch: failures are
logged and degrade to the original behavior. Review-fork agents are
isolated from lifecycle side effects. Includes contract, integration,
loop-path, and real-run_conversation e2e tests (40 tests).
@huangxun375-stack
huangxun375-stack force-pushed the feat/context-engine-hooks branch from a4b2aec to e93da1b Compare June 22, 2026 09:49
@huangxun375-stack

Copy link
Copy Markdown
Author

与#15498重复——两个 PR 都实现了相同的 ContextEngine 生命周期钩子功能(逐回合观察 + 请求组装),跟踪功能请求#23837。 # 15498(after_turn / ingest_message 钩子)是较早提交的 PR。此 PR 被标记为竞争性重复项;维护者可以在两种方法之间进行选择。

@alt-glitch not a duplicateof #15498. Both touch ContextEngine and track #23837,
but the hook set and design differ:

The request-assembly and pre-compress hooks simply don't exist in #15498, and its
per-message ingest is intentionally omitted here. Different approaches — please
don't treat them as identical.

@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 addressing a real ContextEngine limitation. The current diff needs isolation and validation fixes before it can safely provide the stated request-only, fail-open contract.

Problems

  • agent/conversation_loop.py:766 passes canonical messages to the request hook, and agent/turn_finalizer.py:426 passes the same canonical list to observation. A hook can mutate persisted/live state even when it returns None; observation then affects the following memory sync at agent/turn_finalizer.py:443-449.
  • agent/conversation_loop.py:787 accepts every non-None return. [] replaces a valid request; non-list/non-dict values fail later during construction at :800-809. engine_hook() only catches exceptions from the hook, so this is not fail-open.
  • agent/turn_finalizer.py:431-435 forwards only three reconstructed usage fields, while the normalized host usage already includes cache and reasoning buckets at agent/conversation_loop.py:1880-1902.

Suggested changes

  • Copy/isolate hook inputs and test that mutating hooks cannot alter canonical transcript or memory-sync input.
  • Require a non-empty list of dicts before accepting an assembled view; otherwise preserve the original request.
  • Forward the canonical per-turn usage dict and update the public ContextEngine plugin guide.

This is an automated hermes-sweeper review.

_view = engine_hook(
_ce_engine,
"prepare_request_messages",
messages,

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.

This passes the canonical mutable transcript to a hook whose contract says its view is request-only. An engine can mutate messages and return None, leaking changes into persistence despite the intended isolation. Pass an isolated message view and add a mutation regression.

default=None,
logger=request_logger,
)
if _view is not None:

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 non-None invalid result does not fail open: [] replaces the request, while a non-list or non-dict member fails later in the loop. Accept only a non-empty list of message dicts; otherwise keep the original source and host injections.

Comment thread agent/turn_finalizer.py
engine_hook(
_engine,
"on_turn_complete",
messages,

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.

The observer receives the canonical list, then _sync_external_memory_for_turn() receives that same list below. A mutating observer can therefore alter the memory provider's input and the returned transcript. Pass an isolated snapshot instead.

Comment thread agent/turn_finalizer.py
session_id=agent.session_id or "",
turn_id=turn_id,
turn_index=getattr(agent, "_user_turn_count", None),
usage={

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.

This drops the normalized cache and reasoning usage buckets already supplied to update_from_response(). Preserve and forward the canonical per-turn usage dict so an observation engine can assess the actual request rather than only three legacy counters.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state 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
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

Copy link
Copy Markdown
Contributor

The lifecycle surface this PR proposed (request assembly + turn observation) has landed via salvage PR #70458 (from #51226, which consolidated the 4-PR cluster including this one — your PR is credited in its body and in the RFC discussion). The canonical verbs are select_context() (pre-request selection) and on_turn_complete() (post-turn observation with canonical usage); both are no-op-default and fail-open on the ContextEngine ABC.

Thanks @huangxun375-stack for converging on the same design independently — that convergence is what made the consolidation an easy call. If your engine needs a verb the landed pair doesn't cover, a focused follow-up against the new ABC surface is welcome. Closing as consolidated.

@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
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 duplicate This issue or pull request already exists 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-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.

3 participants