Skip to content

feat(kv-unified): callerCacheMarkers, for cache markers the caller's request carries outside the messages - #422

Open
nissa-seru wants to merge 5 commits into
anima-research:mainfrom
nissa-seru:desmond/caller-cache-markers
Open

nissa-seru wants to merge 5 commits into
anima-research:mainfrom
nissa-seru:desmond/caller-cache-markers

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

kv-unified gains kvUnified.callerCacheMarkers: the number of cache markers the caller's own request carries outside the messages, such as a membrane beforeRequest hook's marker on the system prompt. kv-unified then places at most 4 minus that many markers of its own, and keeps a call's cache reference when the request carries its markers plus that many. A caller that sets nothing gets the same placement and the same acceptance as before. With main 9c587a8ba's src, this branch's 11 new tests fail, and the other 10 in their file pass. With this branch's src, all 21 pass. On a simulation of a host whose hook marks the system prompt, main at 9c587a8ba kept no cache reference, and in a long conversation membrane refused its requests 61 times for a fifth marker. With the option set to 1, no request was refused, and every accepted call kept its reference (Tests).

@antra-tess, we're asking you to review this before it's merged, since it changes how kv-unified places cache markers in the requests that carry a resident's context, where the caller sets kvUnified.callerCacheMarkers.

Problem

membrane's docs/formatters.md tells a cm-owned caller to mark its system prompt in a beforeRequest hook ("Through Membrane, whose request's system is a string, a cm-owned caller marks its system prompt in a beforeRequest hook", :129 at 943b8812). agent-framework sends cacheMarkers: 'cm-owned' for kv-unified (src/agent.ts:2056 at fd6d6a362c). A caller that follows that advice meets two things at main 9c587a8ba:

  • kv-unified keeps a call's cache reference only when the request's wire receipt counts exactly the markers its compile placed (src/strategies/autobiographical.ts:14839 at 9c587a8ba). The hook's marker is one more, so no call keeps a reference, and every compile weighs no cache costs.
  • Once history passes its recent window, kv-unified places four markers of its own (:15558–15560 and the tail end, at 9c587a8ba), and the hook's is a fifth. membrane refuses such a request under cm-owned.

On our simulation with a hook (Tests), main at 9c587a8ba logged the mismatch once, and no call kept a reference. In a long conversation, membrane refused the requests 61 times as carrying 5 markers (maximum 4).

Only the caller knows what its hook adds, and a request at four of kv-unified's markers is refused before any receipt reaches kv-unified. So the caller declares the count, rather than kv-unified learning it.

We know of no host that sets such a hook today. connectome-host builds its Membrane with no hooks (src/index.ts:1086–1094 at fab6ab93c6).

Changes

Tests

On Linux with Node 24.22.1, installed with npm ci from the lockfile, in a clean checkout of 81cc29d49, this branch's third commit. This head, b91f1696f, changes only the changelog fragment and docs/unified-solve-design.md after it.

  • npx tsc exited 0.
  • At 81cc29d49: kv-unified-marker-boundaries passed 21 of 21. kv-unified-render-state (55), kv-unified-strategy-integration (33), strategy-persistence (57), derive (26), compression-recall-curve (139) and merge-raw-pin-seam (13) passed whole.
  • Red first: with main 9c587a8ba's src/strategies/autobiographical.ts and src/types/strategy.ts laid over, kv-unified-marker-boundaries failed exactly its 11 new tests and passed the other 10.

Our simulation of a host. These runs used a rig of ours, not part of this pull request: connectome-host 191d4f2c through agent-framework 8145085d06 and membrane 493eb153 to a stand-in provider, with no billing. A preload standing in for a beforeRequest hook marked the system prompt at a one-hour lifetime. Recipes came from connectome-host's own kv-unified settings, with callerCacheMarkers: 1 added where set.

  • At main 9c587a8ba, with the hook: in a short conversation, 19 requests, kv-unified logged its mismatch line once, and none of 18 accepted calls kept a reference. In a long conversation, membrane refused requests 61 times with "streamOnce: cache_control limit exceeded: 5 markers (maximum 4)", 10 requests reached the stand-in, and none of 5 accepted calls kept a reference.
  • At this branch's 3280e9ad9, with the hook and the option set to 1: in the short conversation, 19 requests, all 18 accepted calls kept a reference, and 9 of 10 compiles weighed one. In the long conversation, 44 requests, none was refused and none carried more than 4 markers. 30 carried four, the system marker and three of kv-unified's. All 34 accepted calls kept a reference, and 31 of 32 compiles weighed one.
  • At 3280e9ad9, with the hook and the option unset: as at main, with the mismatch line now naming the option.
  • With fix(kv-unified): only a compile's first accepted call sets its cache reference #410 merged in: this change at 4a15a8cbc with fix(kv-unified): only a compile's first accepted call sets its cache reference #410's 16e08f0c2 merged in, keeping both sides' tests, joining the two comments where they conflict, and adding the caller's declared markers to fix(kv-unified): only a compile's first accepted call sets its cache reference #410's slot-contract sentence, with membrane at feat(cache): float a marker on cm-owned tool-loop calls that ask for it antra-tess/membrane#239's 70c4d2b0. There npx tsc exited 0, and kv-unified-marker-boundaries passed 26 of 26, this change's 21 and fix(kv-unified): only a compile's first accepted call sets its cache reference #410's 5. With the hook and the option set to 1, in the long conversation, 44 requests, none was refused and none carried more than 4 markers. Each of the 8 later calls of a tool loop carried the system marker, kv-unified's later two and one on its newest message, where the turn's first call carried the system marker and kv-unified's three. All 34 accepted calls kept a reference. In the short conversation, 19 requests, none carried more than 4. In its two turns with later calls, the first call carried the system marker and kv-unified's two, and each later call carried those and one on its newest message. With the hook and the option unset, membrane refused requests 61 times as at main.
  • Without a hook, in seven scenarios, main 9c587a8ba against this branch's first commit, a9ba3896e: all 158 requests were byte-identical, apart from the session's start time and a clock line, which differ between two runs of one commit too. context-manager's compile decisions were identical once timestamps were masked.

From 3280e9ad9 to this head, src changes only how the refusal names the value and three doc comments, and the tests, the fragment and the design doc change too. From a9ba3896e, src also changes from reading the count at each use to reading it once, the refusal's wording, and more comments.

Not verified

  • A real provider, and a real hook. The simulation sends to a stand-in, and its hook is a preload standing in for one.
  • A real store. The simulation's conversations are of 19 and 44 requests. No run here compiled against a store of a resident's size.
  • What the reference leaves out. It holds kv-unified's markers only, so the cost weighing doesn't see the caller's own system-prompt cache entry (the type's doc, src/types/strategy.ts:1380–1389). No run measured what that costs.
  • A hook that also moves one of kv-unified's message markers while adding its own keeps the count, so src/strategies/autobiographical.ts:14850 keeps the reference. No test has one.

  • Changelog fragment added: changelog.d/caller-cache-markers.added.md.

Desmond-2579 made the change and ran it, Oscar-1005 made its design calls and read the last fix round, and Beth-2709 checked that each part is needed. Gordon-2414 found the behaviour, on Nigel-2405's simulation. Fiona-2757 wrote this description.

Desmond-2579 added 5 commits October 29, 2026 21:53
…utside the messages

membrane's formatters doc tells a cm-owned caller to mark its system prompt
in a beforeRequest hook. kv-unified couldn't tell that marker from its own:
its accept keeps a call's cache reference only when the wire receipt's
marker count equals the compile's boundaries, so with the hook's marker no
call kept a reference and every compile weighed no cache costs; and once
kv-unified's own four markers were in use, the hook's was a fifth, which
membrane refuses under cm-owned.

kvUnified.callerCacheMarkers (an integer from 0 to 3, 0 unless set) is how
many markers the caller's request carries outside the messages. Only the
caller knows what its hook adds, and a request at four of kv-unified's
markers is refused before any receipt could say so, so the count is
declared, not learned.
- placeCacheMarkers places 4 minus that many: 3 - r history markers at the
  legal boundaries nearest to i/(3 - r) of the history, the last at its last
  legal boundary, and the tail end. At 0 that is the thirds and the tail end,
  as before.
- reportKvUnifiedAccepted keeps the reference when the receipt carries the
  compile's boundaries plus the declared count. The line it logs once on a
  mismatch names the option.
- The strategy refuses a value that isn't an integer from 0 to 3 when it is
  built, and each use reads it the same way.

At 0 the placement, the accept and the guard are what they were, so a
caller that declares nothing sends the same bytes.
…uilt

The placement and the accept each read kvUnified.callerCacheMarkers from the
live config, which holds the caller's kvUnified object by reference. So a
change to that object between a compile and the accept of its request
compared the receipt against a count the compile didn't place for, and a
preview whose overrides replace kvUnified placed its markers as if none were
declared, though the caller's request is the same. The strategy now
reads the count when it is built, checks it there, and keeps it; placement
and accept read what it kept. An unset count is 0, and null reads as unset,
as for the other kvUnified options. The reading function that sat between
AutobiographicalStrategy's doc comment and the class is gone, so the class
has its doc again.

Texts the option made false now say what holds: kv-unified places at most
4 minus the declared count, since targets can share a boundary and short
history has fewer; it keeps the reference only when the wire carries its
markers plus the declared ones; and the comments on placeCacheMarkers, the
mismatch branch, reconcileKvUnifiedMarkerIndices and the render record no
longer say kv-unified owns all four slots or that every wire marker is its
own. The changelog says membrane's formatters doc gives the fifth marker's
refusal, rather than stating it as shown.

Tests:
- The placement table's history entries differ in size, so its expectations
  follow the history's tokens rather than its entry count.
- A declared count keeps the reference for 1, 2 and 3 declared, in both
  scenarios, and the reference's markers sit where their messages end.
- Changing the config object after the strategy is built changes neither
  placement nor acceptance.
- NaN, Infinity and a string are refused, beside 4, -1 and 1.5.
…the preview path

The refusal formatted the value with JSON.stringify, which prints NaN and
Infinity as null and throws on a bigint before the option's message is
built. It now prints a string quoted and anything else as String gives it.

The count is read when the strategy is built, and a preview's overrides
don't change it, since it describes the caller's request; the types doc now
says so. A test previews with overrides that replace kvUnified, without the
count, with 2 and with 4, and each places as the built count does.

The declared-count cases also check where the marks sit: the history's last
boundary, while a history marker remains, and the tail end, as with none
declared. null is taken as unset, as for the other kvUnified options, and a
test says so. The changelog now says membrane refused the fifth marker's
request, which a run with a hook marking the system prompt showed; the doc
comments on placeCacheMarkers and the render record say what they mean.
…s tokens

The fragment said evenly spaced, where placement puts each on the legal
boundary nearest an even fraction of the history's tokens.
The kv-unified design's slot bullet and its configuration table said CM owns
all four cache slots under cm-owned. A caller that declares markers with
kvUnified.callerCacheMarkers holds some of them, so both now say so.
@nissa-seru
nissa-seru requested a review from antra-tess October 30, 2026 20:55
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