Skip to content

feat: per-lane admission model affinity with an explicit model override#942

Draft
gorkem2020 wants to merge 14 commits into
CortexReach:masterfrom
gorkem2020:feat/admission-lane-model-affinity
Draft

feat: per-lane admission model affinity with an explicit model override#942
gorkem2020 wants to merge 14 commits into
CortexReach:masterfrom
gorkem2020:feat/admission-lane-model-affinity

Conversation

@gorkem2020

@gorkem2020 gorkem2020 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

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 llm model, 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:

  1. An explicit admissionControl.model override, on every lane.
  2. Lane affinity, when admissionControl.modelAffinity: "lane": the reflection lane resolves the memoryReflection model (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.
  3. Default ("global", or the knob absent): every lane uses the global model, unchanged from today.

Changes

  • src/admission-control.ts:
    • AdmissionControlConfig gains model?: string (absolute override) and modelAffinity: "global" | "lane" (default "global").
    • resolveAdmissionModel({ admissionControl, lane, globalModel, reflectionModel }) implements the three-tier resolution order above. Pure function, no I/O.
    • Adds createAdmissionController, identical to the one in fix/admission-without-smart-extraction, so the two branches merge cleanly.
  • src/smart-extractor.ts: same controller-injection decoupling as fix/admission-without-smart-extraction (SmartExtractor accepts a pre-built controller instead of building its own). Needed here so index.ts can 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 via resolveAdmissionModel(), and constructs at most two AdmissionController instances. 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) alongside smartExtractor.
  • openclaw.plugin.json: configSchema.properties.admissionControl.properties gains model (string, optional) and modelAffinity (enum ["global", "lane"], default "global"). admissionControl has additionalProperties: false, so without these declarations both new knobs would fail config validation before ever reaching resolveAdmissionModel(), unconfigurable as shipped.

Tests (all new, TDD red-first)

  • test/admission-model-resolution.test.mjs: unit coverage of resolveAdmissionModel(), 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 mocked createLlmClient capturing the model used per constructed client, and asserts the same four scenarios end to end at the plugin-wiring level.
  • Reused verbatim from fix/admission-without-smart-extraction: test/admission-controller-standalone.test.mjs and test/smart-extractor-admission-controller-injection.test.mjs.
  • test/plugin-manifest-regression.mjs: asserts the schema declares model as a string and modelAffinity as 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 master today). 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) and npm run build green.

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.model is 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), but resolveAdmissionModel() handed that raw string straight to the admission-control client, which talks directly to OpenRouter and requires the bare anthropic/claude-opus-4-8 form. Every reflection-lane admission call 400'd.

No memory rows were lost: llm-client.ts's completeJson() never throws on an HTTP failure, it resolves null; scoreUtility() treats that as "Utility scoring unavailable" and substitutes a neutral 0.5 score, 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 literally utility_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() in src/admission-control.ts strips a literal leading openrouter/ segment before resolveAdmissionModel() 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 in test/admission-model-resolution.test.mjs and test/admission-controller-standalone.test.mjs cover 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 to feat/admission-lane-model-affinity and merged forward into the deploy assembly.

@gorkem2020
gorkem2020 force-pushed the feat/admission-lane-model-affinity branch from b7de18d to b0a7d08 Compare July 15, 2026 00:32
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
gorkem2020 force-pushed the feat/admission-lane-model-affinity branch from b0a7d08 to 34ac0cf Compare July 17, 2026 21:36
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
gorkem2020 force-pushed the feat/admission-lane-model-affinity branch from 34ac0cf to 4ed79be Compare July 18, 2026 15:50
…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
gorkem2020 force-pushed the feat/admission-lane-model-affinity branch from 1c48150 to 5fee282 Compare July 18, 2026 19:28
…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
gorkem2020 force-pushed the feat/admission-lane-model-affinity branch from 5fee282 to 9ec99d4 Compare July 18, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant