feat(context-engine): add select_context() + on_turn_complete() hooks (RFC #36765; consolidates #41918/#24949/#47109/#50053) - #51226
Conversation
|
Thanks for putting this together. I’m supportive of consolidating the request-selection/request-assembly surface into one canonical hook, and One thing I want to clarify from #41918: that PR has two separate pieces, and this PR only subsumes one of them.
But #41918 also adds Why
So I’d prefer that this PR either:
I’m not attached to the exact names from #41918. If maintainers prefer Also, since this PR is explicitly consolidating the request-selection surface from #41918, I’d like to be included with a |
|
Good call — you're right that I was over-claiming. Pushed in this PR:
So the PR now subsumes both halves of #41918 — On attribution: I've added you via Agreed on naming flexibility too: if maintainers prefer |
|
Thanks for consolidating the selection surface here. I want to flag a complementary PR so the maintainers can see how they relate, since the title lists
Mapping the two PRs against each other:
So I don't think Happy to align on naming/signatures with whatever you and the maintainers settle on for the selection and observation verbs. Does keeping enforcement as a separate third verb on top of your two sound right to you? |
|
Thanks for adding One smaller but important contract detail from #41918: the hook should receive the actual provider usage metadata when it is available. I noticed the current integration supports a I think it would be worth preserving the original behavior here and forwarding the real usage dict for the completed turn/API call where possible. Why this matters:
So the request is: could this PR preserve the #41918 contract by forwarding actual usage metadata when available, and only falling back to |
Related (competing/consolidating cluster): this PR consolidates RFC #36765 against #41918 ( |
|
@johnnykor82 done — you're right that Pushed in
One semantics call I made, flag if you'd prefer otherwise: a turn can make several API calls (tool loop), and I forward the latest call's usage rather than a per-turn sum. Reasoning: the signal you described — "how large/expensive was the selected context" — is best read from the final assembled request's I also documented the On attribution: you're co-authored on this commit too. I'm still using the |
|
@arminanton agreed on all counts — and you're right that "consolidates #50053" was over-broad. I've corrected it. Your split is exactly the right mental model:
What I changed in the PR:
Thanks for conceding the |
|
@chaos-xxl @johnnykor82 [partially-related-external-topic] When you have some time, could you review the implementation in #51565? I have been using and testing it a lot for the past 3 weeks, but would love to hear your perspectives/suggestions. |
2e8b9e4 to
13c8132
Compare
|
Status update for whoever picks this up:
Nothing needed from reviewers to unblock beyond a look / CI run. Happy to adjust naming ( |
13c8132 to
df34bef
Compare
df34bef to
87ed701
Compare
|
Re: the auto-applied
So the risk surface only exists for an engine that explicitly opts in, and even then the host contract prevents persisted-state mutation. Happy to add an assertion or docstring note if a reviewer wants it more explicit. |
|
@Teknium can you check this out? |
87ed701 to
e2db5b1
Compare
|
Re the #24949 sweeper review's three concerns for a per-request rewrite hook — #51226 already satisfies them:
Just pushed the explicit ordering/cache contract to the docstring plus two tests (no-op byte-stability for cache-control; role-unusual replacement → downstream sanitizers). Happy to align the naming/shape as the canonical surface if useful. |
|
@chaos-xxl A fresh review on #41918 surfaced one fail-open edge case that The current validation is: if isinstance(selected, list) and all(isinstance(m, dict) for m in selected):
return selectedThis correctly rejects non-list values and lists containing non-dicts, but an That matters for the fail-open contract inherited from #41918. If an engine The minimal fix would be to require a non-empty list: if (
isinstance(selected, list)
and selected
and all(isinstance(m, dict) for m in selected)
):
return selectedIt would also be useful to add an One smaller documentation point from the same review: #51226 documents the The |
|
Good catch @johnnykor82 —
Pushed in 98c053a. Thanks for reviewing the consolidated diff directly. |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for consolidating the request-selection and observation surfaces and for following up on the empty-list and public-documentation feedback.
Problems
agent/conversation_loop.py:548-552passes livemessagesandmessages[current_turn_user_idx]to the new hook. A context engine can mutate either object in place, altering persisted transcript state despite the request-only contract. The proposed persistence test attests/agent/test_context_engine_select_context.py:199-215does not perform such a mutation.agent/turn_finalizer.py:420-431is not reached by direct terminal returns such asagent/conversation_loop.py:1137-1150and:1567-1576. Those paths persist and return withouton_turn_complete(), so early failure/interruption does not receive the documentedusage=Noneobservation.
Suggested changes
- Snapshot the hook inputs and add mutation-regression coverage.
- Route all terminal paths through a shared notification/finalization seam, with an integration test for a real early-return path.
Automated hermes-sweeper review.
| # provider response (early failure / interrupt), which is exactly the | ||
| # contract: real usage when available, ``None`` otherwise. | ||
| _turn_usage = getattr(agent, "_last_turn_usage", None) | ||
| _notify_context_engine_turn_complete( |
There was a problem hiding this comment.
This notification only runs for paths that reach finalize_turn. conversation_loop.py still has direct terminal returns after persistence (for example its no-fallback rate-limit path), so those completed failures never invoke on_turn_complete() or receive the documented usage=None behavior. Route terminal exits through a shared notification seam or notify before each such return.
|
Consolidated status on the two review points (commits 1. Live hook inputs → done. 2. Terminal-path coverage → scoped, with the gap documented and the seam left as a follow-up.
Both hooks remain no-op-default and fail-open, so existing engines and the built-in compressor are unaffected. |
a0cc33f to
d184472
Compare
d184472 to
a144569
Compare
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 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>
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>
…tract; add cache-stability + downstream-sanitizer tests - context_engine.py: document that select_context() runs before cache-control and all request sanitizers, so (a) replacements still pass host validation and (b) the no-op default keeps the request byte-stable (AGENTS.md prompt- cache invariant). Note the hook is evaluated per provider request. - tests: no-op path is byte-stable for cache-control; a role-unusual replacement is passed through for the existing downstream sanitizers to normalize (select_context does structural validation only).
… 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).
…on_turn_complete coverage doc Addresses the hermes-sweeper review on NousResearch#51226: - _apply_context_engine_selection now passes shallow copies of the read-only conversation_messages / incoming_message to the hook, so an engine mutating them in place cannot corrupt persisted transcript state (enforces the request-only contract, not just documents it). Adds a mutation-regression test asserting persisted history + incoming message are untouched. - on_turn_complete docstring: scope the coverage claim to the standard finalization seam. Some abnormal early-return paths (content-policy block, provider terminal failure) currently persist+return without finalization and don't emit the hook; documented as best-effort with a shared-seam follow-up, rather than over-promising a guaranteed callback for every early exit.
…ization-seam observation contract - website guide: on_turn_complete() now carries the same best-effort coverage caveat as the ABC docstring (fires from the finalization seam; abnormal early-return paths bypass it) — removes the doc/code inconsistency. - test: finalization seam emits on_turn_complete with usage=None + the interrupted flag for an interrupted finalized turn. Docstring records that the negative early-return-bypass half is best-effort and deferred to a shared-seam follow-up rather than pinned via a full run_conversation harness.
a144569 to
6c686c0
Compare
…on_turn_complete coverage doc Addresses the hermes-sweeper review on #51226: - _apply_context_engine_selection now passes shallow copies of the read-only conversation_messages / incoming_message to the hook, so an engine mutating them in place cannot corrupt persisted transcript state (enforces the request-only contract, not just documents it). Adds a mutation-regression test asserting persisted history + incoming message are untouched. - on_turn_complete docstring: scope the coverage claim to the standard finalization seam. Some abnormal early-return paths (content-policy block, provider terminal failure) currently persist+return without finalization and don't emit the hook; documented as best-effort with a shared-seam follow-up, rather than over-promising a guaranteed callback for every early exit.
… before any per-request work Verification follow-up for the #51226 salvage: the host call site guarded select_context with hasattr(), but the ABC defines a default on every engine, so the built-in ContextCompressor (and any non-implementing engine) still paid per-request shallow copies of the conversation history plus a hook call on every provider request. Identity-check the bound method against ContextEngine.select_context and return the request untouched — mirroring the existing base-method short-circuit in _notify_context_engine_turn_complete — so the default path does zero work, not just produces an identical result. Adds two pins: the base no-op is never invoked (patched-to-raise base stays silent), and ContextCompressor.__dict__ contains neither new verb. Also registers the contributor email mapping for @chaos-xxl.
…-needs only, MemoryProvider for observation-only, cache-stability guidance Maintainer scoping decision for the #51226 salvage: document that select_context() is for engines that must REPLACE per-request context (retrieval/routing) — pre_llm_call is inject-only by documented cache design; that observation-only plugins should implement a MemoryProvider (sync_turn) rather than a context engine, with on_turn_complete scoped as the observation mirror for engines that already select; and that a non-no-op select_context naturally changes the prompt-cache prefix on turns where the selection changes — engines should return stable selections when nothing changed.
|
Landed on main via salvage PR #70458 (rebase-merge b55bb2c) — all seven of your commits with authorship preserved, @chaos-xxl, plus @johnnykor82's Co-authored-by. Outstanding work: the RFC, the dogfooded consumer that surfaced the gap, and the fastest defect-fix turnaround we've seen in a review thread. Two maintainer commits rode on top: (1) a base-method identity short-circuit at the The |
…on_turn_complete coverage doc Addresses the hermes-sweeper review on NousResearch#51226: - _apply_context_engine_selection now passes shallow copies of the read-only conversation_messages / incoming_message to the hook, so an engine mutating them in place cannot corrupt persisted transcript state (enforces the request-only contract, not just documents it). Adds a mutation-regression test asserting persisted history + incoming message are untouched. - on_turn_complete docstring: scope the coverage claim to the standard finalization seam. Some abnormal early-return paths (content-policy block, provider terminal failure) currently persist+return without finalization and don't emit the hook; documented as best-effort with a shared-seam follow-up, rather than over-promising a guaranteed callback for every early exit.
… before any per-request work Verification follow-up for the NousResearch#51226 salvage: the host call site guarded select_context with hasattr(), but the ABC defines a default on every engine, so the built-in ContextCompressor (and any non-implementing engine) still paid per-request shallow copies of the conversation history plus a hook call on every provider request. Identity-check the bound method against ContextEngine.select_context and return the request untouched — mirroring the existing base-method short-circuit in _notify_context_engine_turn_complete — so the default path does zero work, not just produces an identical result. Adds two pins: the base no-op is never invoked (patched-to-raise base stays silent), and ContextCompressor.__dict__ contains neither new verb. Also registers the contributor email mapping for @chaos-xxl.
…-needs only, MemoryProvider for observation-only, cache-stability guidance Maintainer scoping decision for the NousResearch#51226 salvage: document that select_context() is for engines that must REPLACE per-request context (retrieval/routing) — pre_llm_call is inject-only by documented cache design; that observation-only plugins should implement a MemoryProvider (sync_turn) rather than a context engine, with on_turn_complete scoped as the observation mirror for engines that already select; and that a non-no-op select_context naturally changes the prompt-cache prefix on turns where the selection changes — engines should return stable selections when nothing changed.
What does this PR do?
I'm the author of RFC #36765, and this is its implementation. The RFC didn't come from reading the ABC — it came from dogfooding a real context engine (Doctor Chaos, which routes each message to a topic "space" and uses that space's history as the turn's context). Running it on Hermes is what surfaced the gap this PR closes. So this isn't a tidy-up of other people's PRs — it's the concrete landing of a direction that came out of practice, and that a cluster of independent PRs then converged on.
It adds two optional, no-op-default hooks to the
ContextEngineABC — the orthogonal pair the ABC has no vocabulary for today:select_context()— pre-request selection. Called every turn after the request messages are assembled and before provider dispatch, independent ofshould_compress(). Lets an engine select/replace which context enters this one request (retrieval, topic routing, role/branch switching) without mutating persisted history.on_turn_complete()— post-turn observation. Called once after the assistant/tool loop finishes, with the finalized transcript snapshot and the turn's canonical token usage (prompt/completion + cache-read/write/reasoning buckets, orNonewhen no provider response was reached). Lets an engine ingest/index/summarize what actually happened — and weigh how large/expensive the selected context was — so the nextselect_context()can act on it.Both are additive, no-op by default, and fail-open.
should_compress()/compress()semantics are unchanged; the built-in compressor and every existing engine are unaffected. This builds on the#33750host contract, adding the two verbs it didn't cover.Why this matters (from practice, not theory)
Without these hooks, an engine that needs per-turn message access has to force
should_compress()to returnTruesocompress()runs every turn purely as a callback. In my own dogfooding that backdoor caused a real correctness bug: when the engine's backend went down, Hermes' built-in compressor still engaged at its default threshold — aggressively compacting a session nowhere near its budget. Installing the engine and having it fail was worse than not installing it at all (the #29370 shape). A no-op-default, fail-open selection hook removes that class of bug at the source — which is why I'd argue the currentP3undersells it.Related Issue
Implements RFC #36765 (mine).
Consolidates the per-turn request-selection / observation surface that four independent PRs each propose under different names, into one canonical pair:
prepare_request_messages()+on_turn_complete()select_context(), observation →on_turn_complete()preassemble()(per-turn message rewrite)pre_send()+enforce_response()pre_send()(pre-request selection) →select_context();enforce_response()is NOT subsumedCredit to all four — this unifies their shared idea. The
on_turn_complete()design follows #41918; @johnnykor82 is added viaCo-authored-by:(happy to correct the trailer to your preferred address). I'm not attached to the exact names; if maintainers preferafter_turn()/observe_turn()for the observation verb, that's fine.Scope (corrected, per @arminanton's note): this PR consolidates the per-turn request-selection + post-turn observation surface only.
#50053'senforce_response()is a different lifecycle point — active post-generation enforcement that can gate/regenerate/refuse the model's reply, which an observation hook cannot do. It is orthogonal and explicitly out of scope here; the clean split isselect_context()(pre-request selection) +on_turn_complete()(post-turn observation), withenforce_response()riding on top as an optional third verb in a follow-up (@arminanton to re-scope #50053 to just that). My earlier "consolidates #50053" was over-broad on the enforcement half — corrected.Type of Change
Changes Made
agent/context_engine.py: add optionalselect_context(...)andon_turn_complete(...)to the ABC. No-op defaults.agent/conversation_loop.py: add_apply_context_engine_selection()(called after requestapi_messagesare assembled, before Anthropic cache-control) and_notify_context_engine_turn_complete()helpers. Both fail-open; the observation helper short-circuits the base no-op so non-implementing engines pay nothing.agent/turn_finalizer.py: call the observation helper fromfinalize_turn()after the existingpost_llm_callplugin hook, with the finalized transcript + turn metadata, forwarding the turn's canonical usage when available.agent/conversation_loop.py(usage wiring): stash the most recent provider response's usage dict (the same canonical buckets fed toupdate_from_response) on the agent as_last_turn_usage, reset toNoneat turn start so turns that never reach a provider response forwardNonerather than stale usage.tests/agent/test_context_engine_select_context.py: cover both hooks' no-op defaults and every host-call-site branch (None/replace/missing-hook/no-engine/exception/non-list, base-no-op skip, metadata forwarding, shallow-copy semantics, persisted history not mutated).tests/agent/test_context_engine_on_turn_complete_usage.py: integration test through the realfinalize_turnpath — a completed turn forwards the full canonical bucket set intact; a no-provider-response turn forwardsNone.How to Test
The logic of both hooks, all host-call-site branches, and the usage-forwarding wiring was verified standalone on Python 3.13. I still couldn't run the full
pytest tests/ -qsuite locally — my macOS Homebrew Python 3.13 has apyexpat/system-libexpatABI mismatch that breakspipitself (reinstallingpython@3.13+expatdidn't relink it), unrelated to this change. Flagging so a maintainer can confirm against CI.Checklist
Code
pytest tests/ -qand all tests pass — partial: focused logic verified standalone on 3.13; full suite pending CIDocumentation & Housekeeping
cli-config.yaml.example— N/ACONTRIBUTING.md/AGENTS.md— N/A