fix(retrieval): honor context tier ceilings and stop cooling unserved recalls - #3746
Open
t0saki wants to merge 2 commits into
Open
fix(retrieval): honor context tier ceilings and stop cooling unserved recalls#3746t0saki wants to merge 2 commits into
t0saki wants to merge 2 commits into
Conversation
Follow-up to volcengine#3534, from its post-merge review round. - The abstract-to-overview substitute now applies only to categories whose stored abstract is the whole file body. A resource or skill whose abstract is missing (`processing_mode=vectors_only`) or over the per-entry cap read its body and returned an overview instead, which for a short file is the body almost verbatim — crossing the opt-in deepening boundary those categories are documented to have, and doing it even under an explicit `detail="abstract"`. They now degrade to a bare URI and their body is never read. - A digest reporting `no_relevant` blanks `rendered`, so the client injects nothing, yet those URIs still entered the dedup ledger and were cooled for `dedup_turns` turns. That contradicted the ledger's own bare-URI grace rule and held memories back from the later turn they were relevant to. - Flat retrieval reaches built-in memory types outside the four named ones (`cases`, `patterns`, `tools`, `trajectories`, skill-usage memories) and reported them as an undeclared `memories` category that no tier or penalty table covered, so other-peer hits skipped the score penalty and callers could not pin their tier. The catch-all is now a declared category with both; it stays out of `quotas`, whose buckets it would overlap. Skill-usage memories also stop being misread as the `skills` category. - ZCode, OpenCode and pi own an OV session id but did not forward it, so their recalls silently ran without query expansion or cross-turn dedup. - The context-request deadline covered only the server's 30s rewrite fuse, but the pipeline is serial: expansion, retrieval and budgeting all precede it. 45s covers both fuses and the work between them. - `plugin` config scope and the `/recall` successor example now match what the code actually does.
There was a problem hiding this comment.
Pull request overview
This PR is a follow-up bugfix sweep for the server-side context assembly (“/search mode=context”) and its harness plugins, tightening the tier/ceiling contract to prevent unintended body reads, fixing dedup ledger behavior on no_relevant rewrites, and ensuring integrations consistently send session IDs so dedup/query-expansion engage.
Changes:
- Enforce category-specific “abstract substitute” behavior so
resources/skillsnever read or return bodies past their tier ceiling when abstracts are missing/oversized, while keeping the memory-category substitute semantics. - Prevent
no_relevantdigest rewrites from recording planned-but-unserved entries in the dedup ledger; add/extend regression tests around tiering, category reporting, and ledger behavior. - Make the “other built-in memory types” report as a declared
memoriescategory (pin/penalty-aware) and update API validation accordingly; wire session IDs through ZCode/OpenCode/pi harnesses and adjust context request timeout rationale.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/retrieve/test_context_assembler_pipeline.py | Adds regression tests for resource tier ceilings, memory substitute behavior, reported categories, and no_relevant ledger handling. |
| openviking/server/routers/search.py | Distinguishes quota-key vs reported-category validation (CATEGORY_KEYS vs REPORTED_CATEGORY_KEYS). |
| openviking/retrieve/context_assembler/tiers.py | Introduces abstract_substitute() and uses it to avoid substituting resource/skill bodies. |
| openviking/retrieve/context_assembler/pipeline.py | Stops ledger cooling when rewrite status is no_relevant; renames/read-gating uses oversized_abstract_needs_body. |
| openviking/retrieve/context_assembler/params.py | Adds OTHER_MEMORY_CATEGORY (memories), REPORTED_CATEGORY_KEYS, default tier/penalty entries, and gates “full-body abstract” categories. |
| openviking/retrieve/context_assembler/gather.py | Ensures flat retrieval reports unknown memory subtypes as memories and aligns declared-category checks with REPORTED_CATEGORY_KEYS. |
| openviking/retrieve/context_assembler/budget.py | Makes downgrade planning candidate-aware; replaces abstract_over_cap with oversized_abstract_needs_body and uses abstract_substitute() for safe fallbacks. |
| openviking/retrieve/context_assembler/init.py | Exports OTHER_MEMORY_CATEGORY and REPORTED_CATEGORY_KEYS for API/router consumers. |
| examples/zcode-memory-plugin/scripts/zcode-hook.mjs | Forwards derived sessionId into the recall call site. |
| examples/zcode-memory-plugin/scripts/shared/recall-core.mjs | Updates rewrite-request timeout rationale and increases deadline to 45s. |
| examples/zcode-memory-plugin/scripts/shared/plugin-config.mjs | Restricts documented plugin consumers to actual loaders (Claude Code, Codex). |
| examples/pi-coding-agent-extension/tests/recall-deferred.test.mjs | Adds a test asserting session id propagation enables session_id/dedup_turns. |
| examples/pi-coding-agent-extension/shared/recall-core.mjs | Updates rewrite-request timeout rationale and increases deadline to 45s. |
| examples/pi-coding-agent-extension/shared/recall-core.d.mts | Updates type defs to include sessionId option. |
| examples/pi-coding-agent-extension/shared/plugin-config.mjs | Aligns plugin consumer documentation and HARNESS_KEYS with actual usage. |
| examples/pi-coding-agent-extension/recall.ts | Threads a lazy session-id getter into recall requests so server dedup/expansion engage. |
| examples/pi-coding-agent-extension/index.ts | Wires SyncManager.sessionId into RecallManager construction. |
| examples/ovcli.conf.example | Removes inert per-harness plugin.* stanzas that aren’t consumed. |
| examples/opencode-plugin/lib/shared/recall-core.mjs | Updates rewrite-request timeout rationale and increases deadline to 45s. |
| examples/opencode-plugin/lib/shared/plugin-config.mjs | Aligns plugin consumer documentation and HARNESS_KEYS with actual usage. |
| examples/opencode-plugin/lib/memory-recall.mjs | Forwards mapped OV session id to enable dedup/query expansion on context requests. |
| examples/opencode-plugin/index.mjs | Passes sessionManager into recall component for session-id mapping. |
| examples/memory-plugin-shared/recall-session-wiring.test.mjs | Adds source-level assertions pinning session-id wiring in all three harnesses. |
| examples/memory-plugin-shared/recall-core.test.mjs | Updates timeout expectation to reflect serial server pipeline. |
| examples/memory-plugin-shared/lib/recall-core.mjs | Updates rewrite-request timeout rationale and increases deadline to 45s. |
| examples/memory-plugin-shared/lib/plugin-config.mjs | Aligns plugin consumer documentation and HARNESS_KEYS with actual usage. |
| examples/codex-memory-plugin/scripts/shared/recall-core.mjs | Updates rewrite-request timeout rationale and increases deadline to 45s. |
| examples/codex-memory-plugin/scripts/shared/plugin-config.mjs | Aligns plugin consumer documentation and HARNESS_KEYS with actual usage. |
| examples/claude-code-memory-plugin/scripts/shared/recall-core.mjs | Updates rewrite-request timeout rationale and increases deadline to 45s. |
| examples/claude-code-memory-plugin/scripts/shared/plugin-config.mjs | Aligns plugin consumer documentation and HARNESS_KEYS with actual usage. |
| docs/zh/api/16-memory.md | Fixes /recall successor example to match actual response shape. |
| docs/zh/api/06-retrieval.md | Documents memories category, corrected floor/substitute semantics, and no_relevant ledger behavior. |
| docs/zh/agent-integrations/01-overview.md | Clarifies plugin consumer scope and explains the serial-pipeline timeout reasoning. |
| docs/en/api/16-memory.md | Fixes /recall successor example to match actual response shape. |
| docs/en/api/06-retrieval.md | Documents memories category, corrected floor/substitute semantics, and no_relevant ledger behavior. |
| docs/en/agent-integrations/01-overview.md | Clarifies plugin consumer scope and explains the serial-pipeline timeout reasoning. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…able Forwarding a session id turns on server-side query expansion, an LLM call with its own 5s fuse, but neither the deadline that was supposed to cover it nor the switch that turns it off reached the two harnesses this PR newly enabled it for. - `contextRequestTimeoutMs()` now derives the deadline from the request body rather than from `cfg` plus a rewrite flag. The body is what states which server stages will run: a session takes the expansion fuse, `rewrite` takes the digest fuse, and a bare retrieval takes neither and keeps the caller's own budget. Reading `cfg` alone could not tell those apart. - OpenCode pinned `timeoutMs: 5000` after spreading the helper's options and pi ignored them entirely, so the helper's deadline was dead code in both. Their own budgets are now defaults rather than ceilings. OpenCode's 5s in particular was shorter than the expansion fuse it had just enabled, so a legal request would have been aborted client-side and dropped back to the path with neither dedup nor expansion. - OpenCode and pi read `OPENVIKING_RECALL_QUERY_EXPANSION` (and `recallQueryExpansion` in their own config files) and set the `configured` flag the shared body builder requires, so the documented opt-out exists where the cost was introduced. - The integration overview no longer implies every harness reads the same environment knobs, and describes the deadline as per-stage rather than rewrite-only.
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.
Description
Follow-up to #3534, from its post-merge review rounds. Seven defects in the context assembly kernel and the plugins that call it, plus doc and example corrections. Three are contract violations where the code and the reference documentation disagree; the rest cost recall quality or latency without failing anything visibly.
The common thread in the tier fixes is the storage asymmetry #3534 already documented:
memory_updater.pywrites the whole stripped body into a memory row'sabstractscalar because that field doubles as embedding text, while a resource'sabstractis the 256-char summary semantic processing generates. The first revision generalized one heuristic across both shapes, which is safe in one direction and a disclosure boundary crossing in the other.Human Involvement
Related Issue
Follows up on #3534, which implements RFC #3372.
Type of Change
Changes Made
1.
resources/skillsbodies were read and returned past their tier ceilingstart_tier()returnedoverviewfor any candidate whose default or pinned tier wasabstractbut whose stored abstract was empty, and_tiers_down_from("abstract")insertedoverviewinto the downgrade sequence for every category. Both were written for memory files, where the stored abstract is the whole body andoverviewtherefore discloses strictly less — a downgrade despite ranking higher.resourcesandskillshave the opposite shape, so the same substitution reads a body the caller never asked for.params.pydocuments that boundary explicitly: "their bodies are large and may carry credentials, so deepening is opt-in", andDEPTH_CEILING_BY_CATEGORYlists onlyevents.docs/{zh,en}/api/06-retrieval.mddocuments an explicitdetailas "both the requested start and ceiling" with entries that "step down". A resource ingested withprocessing_mode=vectors_onlyhas no abstract, so both statements were false for it.Reproduced on a resource with no abstract and a body of
# Credentials\n\nTOKEN=secret:detail="abstract"detail="overview", body returned verbatimdetail="uri", no readdetail)detail="overview", body returned verbatimdetail="uri", no readdetail="abstract", abstract over the per-entry capdetail="overview", body readdetail="uri", no readNote the second row: this never needed an explicit pin. The default path was reading resource bodies too, which is the wider half of the bug.
doc_overview()on a short file returns the heading tree plus the first paragraph, so for these files the "overview" was the document.Fix:
FULL_BODY_ABSTRACT_CATEGORIESgates the substitution on categories whose abstract is the whole body.abstract_substitute()returnsoverviewfor those andurifor everything else;_tiers_down_from()and the read-gating predicate (renamedabstract_over_cap→oversized_abstract_needs_body) both consult it, so a resource's body is never read on its behalf. Memory categories keep the substitute unchanged.2. A
no_relevantdigest cooled memories that were never servedpipeline.pyblanksrenderedwhen the rewrite returnsNO_RELEVANT_MEMORY, and the plugins return""on that status, so nothing is injected. The ledger write ran anyway, recording every planned entry at itsabstract/overviewtier and cooling those URIs fordedup_turnsturns — five by default. The next turn asking a directly related question could not retrieve them.This also contradicted the ledger's own rule:
cooled_uris()already exempts bare-URI entries because "the entry lost to budget pressure, not to the reader having already seen it". Ano_relevantturn delivers even less than a bare URI, yet was treated more strictly./recallhas no server-side rewrite or ledger, so the regression was confined to the new context face.3. Flat retrieval returned a category outside the response contract
category_for()fell through to a literal"memories"for any hit under/memories/<type>/whose type is not one of the four inMEMORY_CATEGORIES.core/directories.pydefines five more built-in types —cases,patterns,tools,trajectories, and skill-usage memories — all reachable from quota-free retrieval, which searches the user root.The reference table defines
entries[].categoryas six values, and that undeclared seventh value was in no lookup table:penalties.get("memories", 0.0)returned0.0, so another peer's trajectory competed at full score while every declared category was penalizednormalize_detail()filters keys toCATEGORY_KEYS, sodetail={"memories": ...}was silently dropped and callers had no documented way to control the tiermode="list"passes the declared category through untouched, so the new face was strictly worse here than the endpoint it supersedesFix:
OTHER_MEMORY_CATEGORYis now a declared category with a tier default and an other-peer penalty, accepted indetailand in per-category penalties. It stays out ofquotas:category_targets()would resolve it to the memory root, which every other bucket already covers, so validation now splits into quota keys (CATEGORY_KEYS) and reportable keys (REPORTED_CATEGORY_KEYS).This also fixes a misclassification on the same line.
viking://user/{u}/memories/skills/s.mdis a memory about skill usage, but the old fall-through matched/skills/in the URI and returned theskillscategory, applying the wrong tier ceiling and penalty to it — the same shape as the resource-path bug fixed in #3534.4. ZCode, OpenCode and pi never enabled dedup or query expansion
buildRecallBlock()sendssession_id, and with itdedup_turnsand server-side query expansion, only when the caller supplies one. All three harnesses own an OV session id and none forwarded it: ZCode computessessionIdatzcode-hook.mjs:73and calledrecallForPrompt()without options; pi hassync.sessionIdbut constructedRecallManagerwithout it; OpenCode hassessionManager.getMappedSessionId()but never passed the manager into its recall component. Each request reachedmode="context"successfully withdedupreported asoff, so nothing failed — adjacent turns simply re-injected the same URIs, and short queries like "continue that refactor" retrieved on their literal text.pi's
SyncManageris constructed after itsRecallManager, so the id arrives as a getter rather than a value.5. The context-request deadline covered only the last server stage
SERVER_REWRITE_REQUEST_TIMEOUT_MSwas 35s against a 30s rewrite fuse, but the server pipeline is serial and the rewrite is only its final stage: query expansion (recall_intent_timeout_s, 5s) runs first, then retrieval, body reads and budgeting. A request spending 4.8s expanding, 1s retrieving and 29.5s rewriting is inside every server budget and was still aborted client-side at 35s — and since the abort fails the whole request, the plugin fell back to/recalland lost the assembledrenderedblock the server returns even when a rewrite fails. Now 45s, which covers both fuses plus the work between them and leaves a quarter of the 60s prompt-hook budget.6.
pluginconfig named harnesses that do not read itplugin-config.mjsandovcli.conf.examplelistedopencode,cursor,traeandpi, but only Claude Code and Codex importloadPluginSettings(); the rest receive the module throughsync.mjswithout consuming it, and ZCode was in neither the key list nor the example. Settingplugin.cursor.recallLimit=2was silently ignored.HARNESS_KEYS, the example and both locales of the integration overview now name only the two real consumers, with a note to add a key when its loader starts calling the function rather than ahead of it.7. The deadline and the expansion opt-out did not reach the harnesses this PR enables
Fix 4 forwards a session id, and a session engages server-side query expansion —
expansion.pyreturns[query], "off"immediately whensession is None, so before this PR these two harnesses never paid for it. Two separate gaps meant the newly enabled stage arrived without either the budget or the switch it needs.contextRequestTimeoutMs()took(cfg, serverRewrite)and returnedundefinedwhenever no digest was requested. OpenCode and pi set norecallRewrite, so they never requested one and never got any extension — while the server could now spend up torecall_intent_timeout_s(5s) on expansion before retrieval even starts. It now derives the deadline from the request body instead, because the body is what states which stages will run: session takes the expansion fuse,rewritetakes the digest fuse, neither keeps the caller's own budget.Even that would not have reached the wire. Both adapters discarded the value:
memory-recall.mjsspread the helper's options and then pinnedtimeoutMs: 5000after them, andrecall.tspassed a literal10000positionally, ignoringoptions. OpenCode's 5s was shorter than the expansion fuse alone, so a legal request would have been aborted client-side, and since an abort fails the whole request it would have fallen back to/recall— losing the dedup and expansion this PR set out to enable, and paying double latency to do it. Each adapter's budget is now a default rather than a ceiling.Finally, neither harness read
OPENVIKING_RECALL_QUERY_EXPANSION, andbuildContextSearchBody()only emitsquery_expansionwhen the harness also setsrecallQueryExpansionConfigured. Both now read the variable and their ownrecallQueryExpansionconfig key, so the cost introduced here has an off switch where it is paid. The default staysauto, matching Claude Code and Codex and the documented behaviour of expansion as a default-on optional stage.Effective deadline per harness, with each adapter's own budget as the fallback:
query_expansion=off8. Docs
docs/{zh,en}/api/16-memory.md: the/recallresponse example still showed"successor": "/api/v1/search/search?mode=context"; the code returns"/api/v1/search/search"plus a separate"successor_body": {"mode": "context"}, so copying the example into a compatibility assertion would not match a real response06-retrieval.md: thememoriescategory row, the corrected floor rule, theno_relevantledger behaviour, and the note that the memory overview substitute is the one case where the serveddetailmay outrank the pin — and why that is still a content downgradeagent-integrations/01-overview.md: the deadline described per stage rather than rewrite-only, and an accurate statement of which harness reads which knob — the earlier wording implied every harness took the same environment variables, which was false for query expansionTesting
OPENVIKING_CONFIG_FILE=/tmp/ov-test.conf uv run pytest tests/retrieve tests/server/test_api_search_context.py tests/server/test_recall_endpoint.py tests/server/test_recall_peer_scope.py tests/server/test_mcp_endpoint.py tests/test_ovcli_config_schema.py— 144 passed, with/tmp/ov-test.confcontaining{}.Five new Python regression tests: both resource paths that must not read a body (missing abstract under an explicit pin and on the default path, and an oversized abstract), the memory substitute that must survive unchanged, the
category_forcontract together with the penalty and pin lookups that were missing, and theno_relevantturn that must leave the ledger empty.OPENVIKING_STATE_DIR="$(mktemp -d)" node --test $(rg --files examples | rg '\.test\.mjs$' | sort)— 251 passed.On the JS side, pi's recall manager gets a test asserting the session id reaches the request body once a session exists, and a new
recall-session-wiring.test.mjspins all three harness call sites. That file is deliberately source-level: each harness stubs its transport below the point where the session id is added, so no existing unit test could observe the omission — which is how three integrations shipped without dedup. It now also pins the timeout passthrough and the query-expansion opt-out in both adapters, the two gaps that turned the session wiring into a latency regression.The shared core gets a test asserting the deadline tracks the stages the body asks for, including that an explicit
query_expansion: "off"costs no headroom.cd docs && npm run check:api— passes.Checklist
Additional Notes
The tier fixes trade one behaviour deliberately: a resource ingested with
processing_mode=vectors_onlynow returns as a bare URI plus its score rather than an overview of its body. That is the designed floor — #3534 states that every tier carries its URI so the model can drill down through the MCPreadtool, and its bare-URI grace rule keeps such an entry out of the dedup cooldown so it returns on the next turn. The alternative, keeping the substitution, means the default recall path reads resource bodies, which is exactly what the per-category tier table was introduced to stop.eventsstill deepens tofulland every memory category keeps the overview substitute, so this changes nothing on the memory path that the tier-model measurements in #3534 were based on.