feat: per-lane admission model affinity with an explicit model override#942
Draft
gorkem2020 wants to merge 14 commits into
Draft
feat: per-lane admission model affinity with an explicit model override#942gorkem2020 wants to merge 14 commits into
gorkem2020 wants to merge 14 commits into
Conversation
gorkem2020
force-pushed
the
feat/admission-lane-model-affinity
branch
from
July 15, 2026 00:32
b7de18d to
b0a7d08
Compare
gorkem2020
added a commit
to gorkem2020/memory-lancedb-pro
that referenced
this pull request
Jul 15, 2026
parseBatchUtilityResponse invalidated the whole chunk's response on any single missing/misindexed entry, falling the entire chunk back to one standalone evaluate() call per candidate. That silently multiplies LLM calls for a chunk that was mostly fine, defeating the call-count guarantee batch mode exists to provide — and is the same evaluateBatch path a future reflection-lane mapped-row admission gate (composing at assembly with the lane/model-affinity work) would hoist onto, so the guarantee needs to hold generically, not just for the extraction lane's own shapes. Now a missing/malformed entry defaults just that row to a neutral 0.5 utility (matching scoreUtility's own failure default) while every well-formed row in the same response keeps its real batch-scored value. The chunk's call count stays exactly one either way; only a call-level failure (completeJson itself throwing) still falls back to per-candidate standalone calls, since that's a materially different failure mode (no response at all, not a partially-malformed one). Added a controller-layer composition test proving evaluateBatch calls stay isolated per AdmissionController instance, so a lane-scoped LLM client (e.g. a future reflection-lane controller under model affinity) never crosses into another lane's batch — the composition point CortexReach#942 will need already holds today with no further plumbing in this file. Red-proved: both new/changed malformed-entry tests failed against the prior code (3 and 5 unwanted standalone calls respectively) before the fix, and pass after.
gorkem2020
added a commit
to gorkem2020/memory-lancedb-pro
that referenced
this pull request
Jul 17, 2026
parseBatchUtilityResponse invalidated the whole chunk's response on any single missing/misindexed entry, falling the entire chunk back to one standalone evaluate() call per candidate. That silently multiplies LLM calls for a chunk that was mostly fine, defeating the call-count guarantee batch mode exists to provide — and is the same evaluateBatch path a future reflection-lane mapped-row admission gate (composing at assembly with the lane/model-affinity work) would hoist onto, so the guarantee needs to hold generically, not just for the extraction lane's own shapes. Now a missing/malformed entry defaults just that row to a neutral 0.5 utility (matching scoreUtility's own failure default) while every well-formed row in the same response keeps its real batch-scored value. The chunk's call count stays exactly one either way; only a call-level failure (completeJson itself throwing) still falls back to per-candidate standalone calls, since that's a materially different failure mode (no response at all, not a partially-malformed one). Added a controller-layer composition test proving evaluateBatch calls stay isolated per AdmissionController instance, so a lane-scoped LLM client (e.g. a future reflection-lane controller under model affinity) never crosses into another lane's batch — the composition point CortexReach#942 will need already holds today with no further plumbing in this file. Red-proved: both new/changed malformed-entry tests failed against the prior code (3 and 5 unwanted standalone calls respectively) before the fix, and pass after.
gorkem2020
force-pushed
the
feat/admission-lane-model-affinity
branch
from
July 17, 2026 21:36
b0a7d08 to
34ac0cf
Compare
All four internal calls (extract-candidates, admission-utility,
dedup-decision, merge-memory) packed instructions, criteria, identity, and
the output-format contract into a single user-role message with no system
prompt. Restructure each builder to return {system, user}: static
instructions/criteria/format go in system, the per-call conversation
excerpt / candidate rows / neighbor rows go in user.
Give each stage an honest, distinct identity (extraction agent, dedup
decider, merge writer, admission judge) instead of every call introducing
itself with the same generic extraction-assistant wording.
Rebuild the merge prompt template cleanly: the old version rendered
broken bold markers ("** Category **:"), progressively indented list
items, and split-hyphen tokenization artifacts ("up - to - date").
Thread an optional sourceKind through the admission prompt builder so a
reflection-sourced excerpt is honestly framed as "Source document (agent
reflection)" rather than "Conversation excerpt" (default unchanged). The
actual reflection call site lives on a not-yet-merged branch and will
pass sourceKind: "reflection" when that branch is integrated.
LlmClient.completeJson() gains an optional systemPrompt third argument.
The api-key path threads it into the messages array's system role; the
OAuth path threads it into the Responses API's existing `instructions`
field (which already carried system-prompt semantics, so no prepend
fallback was needed). Both default to the historical generic system text
when omitted, so memory-upgrader.ts's unrelated call site is unaffected.
Update three existing test files whose mock LLM servers routed responses
by matching prompt-opener phrases in the user message; those phrases now
live in the system message, so the mocks combine both messages before
matching. No scoring weights, thresholds, or decision protocol changed.
…uations - buildDedupPrompt and buildMergePrompt: separate each Abstract/Overview/ Content field with a blank line so markdown embedded in Overview/Content doesn't visually collapse into the label line above it. - admission-control's buildUtilityPrompt: indent continuation lines of a multi-line Overview/Content so they stay nested under their bullet instead of landing flush-left mid-list. - smart-extractor's dedup candidate listing: extract formatExistingMemoryForDedupPrompt so the same continuation-line indentation applies to the numbered existing-memories list, and the formatting is directly unit-testable.
…ission few-shot example buildUtilityPrompt's Overview/Content bullets were glued to Category/ Abstract with a single newline; give them their own blank-line-separated block like the dedup/merge prompts already do. The system prompt had no few-shot example at all, so add one wrapped in explicit "--- EXAMPLE (not part of the live data) ---" / "--- END EXAMPLE ---" markers and a clearly labeled "Example response:" line, matching the fencing convention already established for the batch-utility prompt on a sibling branch. No batch variant of buildUtilityPrompt exists on this branch (or on master) to formatting-match against -- it lives on the separate, unmerged feat/admission-batch-utility branch, out of scope here.
buildReflectionPrompt returned one flat string with the distiller's
identity, heading contract, hard rules, section rules, governance format,
and output template all glued to the per-call tool-error-signal and
conversation payload. Split it into {system, user} like the other four
LLM call sites: system carries every static instruction (now opening
with an explicit "You are a memory reflection distiller." identity
line, matching the extraction/dedup/merge/admission stage-identity
convention), user carries only the per-call tool error signals and the
fenced conversation INPUT block.
generateReflectionTextUnbounded rejoins system + "\n\n" + user before
handing the result to the embedded-agent runner and the CLI fallback,
since neither transport (runEmbeddedPiAgent's params bag, the CLI's
--message arg) has a distinct system-prompt field to split into -- same
mechanical-merge posture already used on the sibling admission-batch-
utility branch for its own not-yet-split call site. A test asserts the
rejoined text is byte-identical to the prior single-string prompt, so
this is a pure prompt-architecture/formatting change with no behavior
delta: same headings, same rules, same output-format contract.
index.ts carries this repo's known mixed CRLF/LF encoding; both the
function body and its call site were rewritten with a latin1 Buffer
splice (never Edit) to avoid normalizing the file, per this fork's
CLAUDE.md. Diff is 10 lines.
Durable/recurring state and habit changes (e.g. "switched my commute to the M4", "Spanish lesson before breakfast") were getting classified as "events" by the extraction LLM and then hugging the admission threshold instead of persisting as the user's new ongoing state. Add a compact rubric bullet to the extraction prompt's Common Confusion section: recurring/durable state and habit changes route to preferences or patterns; events stays reserved for genuinely one-off occurrences.
Wire test/extraction-category-rubric.test.mjs into package.json's local test chain and scripts/ci-test-manifest.mjs's llm-clients-and-auth group, alongside its sibling prompt-architecture.test.mjs.
gorkem2020
added a commit
to gorkem2020/memory-lancedb-pro
that referenced
this pull request
Jul 18, 2026
parseBatchUtilityResponse invalidated the whole chunk's response on any single missing/misindexed entry, falling the entire chunk back to one standalone evaluate() call per candidate. That silently multiplies LLM calls for a chunk that was mostly fine, defeating the call-count guarantee batch mode exists to provide — and is the same evaluateBatch path a future reflection-lane mapped-row admission gate (composing at assembly with the lane/model-affinity work) would hoist onto, so the guarantee needs to hold generically, not just for the extraction lane's own shapes. Now a missing/malformed entry defaults just that row to a neutral 0.5 utility (matching scoreUtility's own failure default) while every well-formed row in the same response keeps its real batch-scored value. The chunk's call count stays exactly one either way; only a call-level failure (completeJson itself throwing) still falls back to per-candidate standalone calls, since that's a materially different failure mode (no response at all, not a partially-malformed one). Added a controller-layer composition test proving evaluateBatch calls stay isolated per AdmissionController instance, so a lane-scoped LLM client (e.g. a future reflection-lane controller under model affinity) never crosses into another lane's batch — the composition point CortexReach#942 will need already holds today with no further plumbing in this file. Red-proved: both new/changed malformed-entry tests failed against the prior code (3 and 5 unwanted standalone calls respectively) before the fix, and pass after.
gorkem2020
force-pushed
the
feat/admission-lane-model-affinity
branch
from
July 18, 2026 15:50
34ac0cf to
4ed79be
Compare
…tities, markdown payload shape, raw-JSON contracts
Fleet-proven prompt-architecture foundation, ported from the deployed
build:
- src/prompt-blocks.ts single-sources the shared static prompt content:
agent identities (every prompt opens 'You are a memory ...'), the
six-category taxonomy line, the scoring rubric, bare JSON shape
rendering (jsonShape - examples are shown exactly as the model must
emit them, unfenced; fenced examples taught fence-mimicking models to
fence their output), and the markdown candidate/memory formatters.
- Candidate and existing-memory payloads render as markdown blocks
('## Candidates' / '### 1. <category>') through one shared formatter.
- Every output contract reads 'Return JSON only (the raw object, no
markdown code fences)' and shows the shape bare.
- The admission judge is transcript-free: it scores the candidate's own
content plus store-derived features, never a conversation excerpt.
- The dedup decider carries the anti-category-wall doctrine (category
labels never decide a verdict by themselves) and the extraction
transcript rides a fenced block like the distiller's INPUT.
- Distiller opener names the domain and forbids fenced output.
gorkem2020
force-pushed
the
feat/admission-lane-model-affinity
branch
from
July 18, 2026 19:28
1c48150 to
5fee282
Compare
…nk knob, split transport slots Rebuilt on the prompt-architecture foundation (this branch now stacks on fix/prompt-architecture): - evaluateBatch scores utility for a whole extraction batch with exactly one LLM call per chunk; a malformed response entry degrades only that row, never the chunk's call budget. - utilityVetoThreshold (default 0.25, presets + manifest): utility scores at or below the floor reject regardless of the weighted composite. A fleet trace motivated it: a session-scoped candidate the judge scored 0.2 still passed at composite 0.596 because the preferences type prior alone clears the reject bar. Degraded utility calls (failure default 0.5) can never trip the veto; 0 disables it. - batchChunkSize (1-50, default 10) bounds every batched stage's per-call chunk: batch utility, batched dedup decider, batched merge writer. - The batched pipeline calls submit system/user through completeJson's system parameter; the batch grounding-rule paragraph rides the batch utility prompt like the standalone one.
…rride and direct-client model normalization Stacks on the batch-utility branch. modelAffinity 'lane' routes the whole reflection pipeline — admission judge, dedup decider, merge writer — onto the memoryReflection model via a dedicated lane client handed to the shared pipeline; 'global' (default) keeps today's behavior. An explicit admissionControl.model override governs admission calls only, never the lane pipeline client. Every admission-lane client model passes through normalizeDirectModelRef so a core-style catalog ref (openrouter/vendor/ model) reaches the plugin's direct OpenRouter client provider-stripped — a raw catalog ref 400s at the API (found live: the lane dedup call failed while the resolver-normalized judge call succeeded). batchChunkSize threads through plugin config into every batched stage.
gorkem2020
force-pushed
the
feat/admission-lane-model-affinity
branch
from
July 18, 2026 19:35
5fee282 to
9ec99d4
Compare
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.
Stacked on #931 — merge that PR first. This PR's own commits start at 4ecbe71.
This branch was rebased onto
fix/reflection-mapped-rows-admission(#931) because this PR's reflection-lane admission controller has no consumer without #931's mapped-row gate (gateMappedReflectionEntry), so the diff below includes #931's commits.Admission calls always used the global
llmmodel, even for the reflection-mapped-rows gate whose job is auditing rows the reflection distiller itself produced with a (possibly different)memoryReflection.model. This adds explicit model resolution for admission calls, in order:admissionControl.modeloverride, on every lane.admissionControl.modelAffinity: "lane": the reflection lane resolves thememoryReflectionmodel (falling back to the global model if none is configured, so the judge is never dumber than the author), while extraction and fallback admission stay on the global model."global", or the knob absent): every lane uses the global model, unchanged from today.Changes
src/admission-control.ts:AdmissionControlConfiggainsmodel?: string(absolute override) andmodelAffinity: "global" | "lane"(default"global").resolveAdmissionModel({ admissionControl, lane, globalModel, reflectionModel })implements the three-tier resolution order above. Pure function, no I/O.createAdmissionController, identical to the one infix/admission-without-smart-extraction, so the two branches merge cleanly.src/smart-extractor.ts: same controller-injection decoupling asfix/admission-without-smart-extraction(SmartExtractoraccepts a pre-built controller instead of building its own). Needed here soindex.tscan hand extraction the lane-appropriate controller. This commit is intentionally byte-identical to the corresponding one in that branch.index.ts: resolves the extraction-lane and reflection-lane models viaresolveAdmissionModel(), and constructs at most twoAdmissionControllerinstances. The reflection-lane one reuses the extraction-lane client/controller whenever both resolve to the same model (the default case, and the explicit-override case), so no extra LLM client is built unless lane affinity actually picks a different model. Both controllers are exposed on the plugin singleton state (admissionController,admissionControllerReflectionLane) alongsidesmartExtractor.openclaw.plugin.json:configSchema.properties.admissionControl.propertiesgainsmodel(string, optional) andmodelAffinity(enum["global", "lane"], default"global").admissionControlhasadditionalProperties: false, so without these declarations both new knobs would fail config validation before ever reachingresolveAdmissionModel(), unconfigurable as shipped.Tests (all new, TDD red-first)
test/admission-model-resolution.test.mjs: unit coverage ofresolveAdmissionModel(), covering the default (both lanes on global), lane mode (reflection lane on the memoryReflection model), lane mode with no memoryReflection model configured (falls back to global), and explicit override beating lane affinity on both lanes.test/admission-lane-model-affinity.test.mjs: registers the plugin with a mockedcreateLlmClientcapturing themodelused per constructed client, and asserts the same four scenarios end to end at the plugin-wiring level.fix/admission-without-smart-extraction:test/admission-controller-standalone.test.mjsandtest/smart-extractor-admission-controller-injection.test.mjs.test/plugin-manifest-regression.mjs: asserts the schema declaresmodelas a string andmodelAffinityas an enum containing both"global"and"lane", defaulting to"global".Scope note
This branch only wires the extraction lane end to end (the only admission consumer that exists on
mastertoday). The reflection-mapped-rows gate and the regex-fallback gate (from separate, still-open PRs) aren't on this branch to update, so a genuine "batch judgment on the reflection model" integration test spans two other branches' code and is added in the deploy assembly instead, once all three land together.Full local test chain green (
npm test, minus the pre-existing host-Ollama port conflict on 11434, unrelated to this change) andnpm run buildgreen.Addendum; live-fleet model-string normalization fix
A live-fleet probe found that
modelAffinity: "lane"was a silent no-op in production. Root cause:memoryReflection.modelis commonly configured in core-style provider-prefixed form (e.g.openrouter/anthropic/claude-opus-4-8, the form the reflection distiller's own embedded runner accepts), butresolveAdmissionModel()handed that raw string straight to the admission-control client, which talks directly to OpenRouter and requires the bareanthropic/claude-opus-4-8form. Every reflection-lane admission call 400'd.No memory rows were lost:
llm-client.ts'scompleteJson()never throws on an HTTP failure, it resolvesnull;scoreUtility()treats that as"Utility scoring unavailable"and substitutes a neutral0.5score, and the other four non-LLM features (confidence/novelty/recency/typePrior) still produce a full audit and decision. That degrade path was already correct, accidental behavior; it's now pinned with an explicit test (test/admission-controller-standalone.test.mjs) rather than left implicit. The visible tell in a stored audit is literallyutility_reason: "Utility scoring unavailable", not genuine LLM prose, so lane affinity's actual effect (judging reflection rows with the reflection model) was silently not happening, at the cost of one wasted 400 per row.Fix:
normalizeAdmissionModelRef()insrc/admission-control.tsstrips a literal leadingopenrouter/segment beforeresolveAdmissionModel()returns, on all three resolution paths (explicit override, lane-resolved, global default). Bare<vendor>/<model>and@preset/<name>strings already work against OpenRouter directly and pass through unchanged. Six new tests intest/admission-model-resolution.test.mjsandtest/admission-controller-standalone.test.mjscover the normalization (core-style prefix stripped, bare passthrough,@preset/passthrough, explicit override normalized the same way) and pin the failure-path degrade contract. Full test chain, build, and manifest verify all green; pushed tofeat/admission-lane-model-affinityand merged forward into the deploy assembly.