feat: strategy-view composition — viewFilter, auxiliary merged slots, windowed passthrough (tune-out groundwork) - #54
Conversation
|
Per review feedback: converted to draft, stacked on the forthcoming tune-out end-to-end PR in agent-framework — mechanically separable but semantically coupled, so this merges only as part of the full integration review, or not at all. The af PR will link back here; treat the pair as one reviewable unit. (If closing until then is preferred, happy to — nothing here is load-bearing standalone.) 🤖 Generated with Claude Code |
0e4b382 to
74935fd
Compare
|
Un-drafted: the end-to-end half is now up as agent-framework#115 — review the pair as one unit per the earlier agreement. Both branches are rebased onto current mains and fully green. Merge sequencing at integration: this first → publish → #115 flips its git-ref pin to the published version. 🤖 Generated with Claude Code |
Concurrent PRs editing the shared '## Unreleased' section of CHANGELOG.md conflict whenever one PR outlives another merge (e.g. anima-research#54, which edits CHANGELOG.md directly and is exposed to every entry that lands while it is in review). Entries now land as uniquely-named fragment files (changelog.d/<slug>.<category>.md), which git merges without conflict; the npm-version hook folds them into the release section and deletes them. Direct '## Unreleased' edits remain supported and are merged at the same point, so in-flight PRs need no rework. Tag-time publish guard and github-release job are unchanged. Part of the ecosystem-wide rollout of this scheme; reference implementation and full rationale in anima-research/agent-framework#128. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
slimepriestess
left a comment
There was a problem hiding this comment.
CHANGES REQUESTED at head 75cdad8 — two mechanical items, everything substantive verified and good. This is approval-shaped: fix the file encoding and rebase, and it has my approve.
Blocker 1 — test/tune-out-compression-framing.test.ts is a binary file. There is a raw NUL byte (offset 4309, line ~105) inside the template literal joining ${m.participant} to the content — the '\0' collision-proof-separator trick written as the literal byte instead of the two-character escape. It compiles and the test passes (verified), but git flags the file binary, so GitHub renders "Binary file not shown": the one test whose whole job is to be a readable contract pin for the AF half is unreviewable in the PR UI, and lands unreadable in the merge history. One-character fix: use the \0 escape (or a printable separator).
Blocker 2 — rebase. CONFLICTING against current main, but I test-merged it: only CHANGELOG.md and package.json conflict; context-manager.ts auto-merges. I also checked the thing an auto-merge could silently miss: current main still has exactly the four strategy-read sites your choke point converts (select, preview, renderStats, StrategyContext) — nothing new grew in the three weeks of kv-unified churn. The rebase is genuinely mechanical.
Verified first-hand:
- The choke-point argument holds. Emission-level exclusion fails both ways you name (
assertFullCoveragejudges against the whole view;rebuildChunksruns pre-selection, so excluded content would still fold into L1s). Routing every strategy read throughstrategyMessageView()closes both, and direct accessors staying unfiltered keeps the backlog dumpable — which the AF half's cancel-dump depends on (it readsgetAllMessages(); confirmed against #115's actual hook). - Merge ordering is sound: sequence is branch-global under the store's single write lock, so cross-slot interleave is deterministic; merge-then-filter order means the filter sees the merged world. The cross-instance freshness test (cache revalidation) is the right pin for the subconscious's live-reader seat.
- WindowedPassthrough re-anchoring: coarse jump + persisted anchor + dryRun guard all correct; the always-make-progress clause (single oversized message) avoids the empty-window wedge. The KV rationale is exactly the fleet's compiled-prefix doctrine applied to strategy-less consumers.
- Suite: 479/479 on the branch (0 fail — your NUL-byte test included).
Nonblocking notes:
mergeMessageStoreViewsre-sorts on everygetAll()— O(n log n) per strategy read, andgetFrom/getTaileach call it again. Only managers configured with aux views pay it, and the subconscious's windowed strategy reads once per select, so fine today; worth a comment so nobody hangs a hot path off a merged view later.package.jsongains apreparescript. I can see why (it lets the AF half's git-ref dependency build on install), but it's undeclared in the PR body and runs on everynpm installfor everyone — worth one sentence of intent, or dropping it if the published-version flip at integration makes it moot. (Also: trailing newline got eaten.)filterMessageStoreView.getFrom(index)indexes into the filtered world — consistent with everything else here, just worth saying in the docstring since raw-store indices are a plausible caller confusion.
The viewFilter determinism contract in the docstring — predicate keyed on ingestion-stamped metadata, never derived state — is the load-bearing sentence of the whole design, and the AF half honors it (permanent stamp at ingestion). Good seam, well argued.
Anarchid
left a comment
There was a problem hiding this comment.
Changes requested at head 75cdad8 — no blockers beyond what @slimepriestess already filed, but four substantive majors that should land before this merges. The primitives are well-shaped, #115 uses them exactly as documented, and the drift against main is shallower than the CONFLICTING badge suggests: I built the merged tree against main bc36b72 (29 commits of drift) and it passes 630/630 tests with clean typecheck/build. The work is in the windowed strategy's edges — branch switches, cache economics, the hard budget — and in pinning the composition contract for derived autobiographical state.
On @slimepriestess's review: I defer entirely on the two blockers (the NUL-byte root cause is exactly right — verified byte-for-byte, the separator at offset ~4308 written as a literal instead of the \0 escape — and the rebase scoping matches my test-merge: only CHANGELOG.md plus one package.json scripts line; keep main's version hook and this branch's prepare hook). Two of the verified-fine bullets, though, I have to qualify below (items 2 and 3) — the mechanisms are sound for the risk they were checked against, but each has a second failure mode.
Majors
1. WindowedPassthroughStrategy.initialize() doesn't reset the anchor — stale anchor survives branch switches.
windowed-passthrough.ts:78-90 assigns this.anchor only when persisted state exists; there's no this.anchor = 0 reset first. ContextManager.switchBranch (context-manager.ts:510-514) re-runs initializeStrategy specifically so branch-scoped state reloads — its doc comment names exactly this class of derived in-memory state. Switch to a branch with no persisted anchor and the old branch's anchor survives; if it exceeds the new branch's head sequence, select() returns [] and the agent silently runs with an empty context, and the next re-anchor persists the stale-derived value into the new branch. Fix: reset to 0 at the top of initialize() and validate the loaded value. Related, for #115: undo/redo there switches the shared store directly (framework.ts:3607-3608, :3667) without re-initializing any manager, so the subconscious's CM keeps the wrong anchor even when the destination branch does have its own persisted value — worth carrying over to that review.
2. The strategy never places a cacheMarker, so its cache-stability rationale never materializes.
The docstring (windowed-passthrough.ts:20-28) sells reAnchorFraction as amortizing prompt-cache invalidation ("one accepted cache invalidation per jump"). The compiled-prefix doctrine is right — but on this path breakpoints only originate from entries with cacheMarker: true (context-manager.ts:586 on main → cacheBreakpoint → the Anthropic formatters), and neither this strategy nor anywhere in #115 sets one. AF's promptCaching: true covers system/tools only; membrane's automatic placement belongs to its separate rolling-context module, and its floating marker only covers rebuilds inside one tool loop. Net effect: the subconscious re-sends its entire merged window — by design ~0.5–1.0× budget — uncached on the first request of every wake. We've measured this class before on plain Passthrough: ~2.8× input overspend. Fix: emit cacheMarker: true at the stable window endpoint each compile (membrane's residual-claim logic from #50 handles the float; mind the four-slot contract from #73), or drop the cache rationale from the doc and state the cost honestly.
3. An oversized newest message defeats the hard token budget.
The always-make-progress clause (windowed-passthrough.ts:150-154) does avoid the empty-window wedge — but when that single message's estimate exceeds maxTokens - reserveForResponse, it's returned with no further budget check (:165-175), and compile() performs no post-select enforcement. Sibling PassthroughStrategy.selectFromEnd (passthrough.ts:71-96) breaks before exceeding budget — fewer entries, never overflow. The option doc at :35-38 says the framework truncates "oversized tool results / attachments" against maxMessageTokens, but #115's clamp covers tool-result text only (framework.ts:7740-7776); image blocks pass through intact (:7727-7733) and ordinary user messages aren't clamped at all. One large user message or tool-result image → compile output above the usable budget → provider context-length hard error. Fix: never return an entry whose estimate exceeds the usable budget (truncate with a marker, or throw OverBudgetError — exported from the root since #71), and narrow the doc claim to the paths the framework actually clamps.
4. Persisted autobiographical summaries bypass viewFilter — derived memory can re-expose hidden history.
The filter wraps only the live view (context-manager.ts:185-193). AutobiographicalStrategy loads persisted summaries with no visibility check (autobiographical.ts:1733-1773), and the hierarchical selector admits unmerged summaries filtered only by the anti-redundancy exclusion set, never by leaf-message visibility (:4549-4577; same on current main). Reopen an already-compressed store with a new or tighter filter, and a summary authored from now-hidden messages remains eligible for emission. The live path is genuinely clean — onNewMessage ignores its message argument and rebuilds chunks from the filtered view (:3588-3590) — so this bites precisely on the tune-out rollout scenario: stores compressed before the filter existed, or a predicate change. Fix: either invalidate/rebuild summaries whose leaf set isn't fully visible, or document loudly that viewFilter is not retroactive over derived state and not a confidentiality boundary. Either way, the missing regression is: persist a summary, reopen with a filter, prove its content cannot compile.
Test coverage
5. The central claim (context-manager.ts:71-78, message-view.ts header) — chunking, selection, emission, and the coverage invariants all see the excluded-free world — is exercised for the wrappers, passthrough, and windowed, but never for AutobiographicalStrategy under an active viewFilter. Autobio's coverage machinery under a filtered view is the historically wedge-prone surface here; one test driving autobio compression with a filter installed would pin the whole claim (and can double as 4's regression).
Minor
6. No guard against an auxiliary slot aliasing the manager's own slot: the own slot resolves un-namespaced unless isolate (context-manager.ts:229), aux stores are built with no identity comparison (:277-291), and the merge concatenates happily. auxiliaryMessageViews: [{}] on a non-isolated manager — or the same aux namespace twice — silently duplicates every message and doubles token accounting. #115 is safe today (isolate: true). Throw in open() on identity collision; dedupe repeats.
Verified fine (beyond slimepriestess's list)
MessageStoreView/StrategyContext/ContextStrategy are byte-identical merge-base→main, so the view wrappers stay conformant across the drift; wrapper getFrom/getTail/negative-index semantics match MessageStore.getFrom; entry conventions match PassthroughStrategy; dryRun gating keeps previews from moving the anchor; the prepare hook is the right lifecycle for npm git-dep installs and bun consumers bypass it via exports.bun; #115's construction (isolated primary + shared aux, mirrored maxMessageTokens) matches the documented contract; and main's branch-keyed message-store caches keep merged views fresh across live aux writes and branch switches.
Method: two-pass review — a forest/integration pass (including the merged-tree build and full test run against current main) merged with an independent static pass by GPT-5.6 Sol in a read-only worktree; every finding was re-verified against the cited lines before inclusion. Items 1, 2, 4, and 6 were found independently by both passes.
🤖 Generated with Claude Code
… windowed passthrough
Groundwork for tune-out (agent-framework#77), usable independently. Three
primitives, all inert until configured:
- ContextManagerConfig.viewFilter: strategy-facing exclusion applied at
the single view choke point (strategyMessageView), so chunking,
selection, emission, and the coverage invariants all see the same
excluded-free world. Excluded messages stay in the store and in direct
accessors. Exclusion any later than this either trips
assertFullCoverage (judged against the whole view) or leaks excluded
content into L1 compression via rebuildChunks.
- ContextManagerConfig.auxiliaryMessageViews: additional message slots
merged read-only into the strategy view, interleaved by chronicle
sequence — branch-global across slots, so the merged timeline is
deterministic and append-only. Writes still target only the manager's
own slot. MessageStore's getAllInternal revalidates against live
chronicle state (count + last-item identity), so cross-instance reads
are fresh without new plumbing.
- WindowedPassthroughStrategy: passthrough over a sequence-anchored
window with COARSE re-anchoring — on overflow the anchor jumps so the
window is ~reAnchorFraction of budget, then stays put; between jumps
the compiled output is a byte-stable prefix plus appends. (A naively
sliding front re-busts the KV prefix every compile — the pathology
kv-stable solves for main agents, which this strategy's consumers
don't get.) Anchor persists in a {ns}/windowed:anchor snapshot slot
and follows branches; setAnchor() is the external policy hook.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hrough Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The anima-research#77 constraint — payload builders must not strip the backlog wrapper or the subconscious's attribution when the resident's window folds — holds by construction today: the builders strip exactly four classes (raw-message thinking, empty text, unpaired tool blocks, oversized images) and preserve participants, which multiuser formatting renders as name prefixes in the summarizer's view. This turns that into a contract: extending payload sanitization must not eat these artifacts. Output register (first-person folding) is deliberately unconstrained here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Surfaced as ContextStrategy.maxMessageTokens so side-process agents can mirror their principal's per-message truncation ceiling (antra: named side agents run under the same settings as the main agent — reasoning effort, attachment/message size limits). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consumers may pin this package as a git dependency (agent-framework#115 pins the tune-out branch until publish). A git install delivers raw source, and main points at dist/ — without prepare, the module resolves to nothing and the consumer's type-check fails (observed on #115 CI). prepare builds on git-install; harmless elsewhere (build is idempotent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…not a raw NUL byte The raw byte flagged the file binary, so GitHub rendered the contract pin as 'Binary file not shown' (review on anima-research#54). Runtime-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Both blockers addressed — thanks for the byte-level diagnosis, that was exactly it.
Your four majors are a separate round — working through them next, in order (anchor reset + validation, cache marker, hard-budget guard, summary visibility + the autobio-under-filter test), plus the aux-slot aliasing guard. |
75cdad8 to
0ff18b2
Compare
…policy, cache markers Review on anima-research#54 (Anarchid, majors 1–3): 1. The in-memory anchor is branch-scoped state. initialize() now resets it before loading, validates the persisted value against the branch head (a value past the head resets to 0 instead of yielding an empty window and then persisting itself into the branch), and every entry point re-derives it when the store's branch is observed to have changed — the same branch-generation identity the autobiographical strategy uses to detect host-side switches that bypass ContextManager.switchBranch (agent-framework's undo/redo switch the chronicle directly). No host change needed on that side. 2. The strategy now places the cache markers its docstring promised: ≤ 2 message-level markers under the shared ≤ 3 first-claim contract — the last entry whose bytes survived from the previous committed compile (naming the prior request's endpoint explicitly, since Anthropic's backward search covers ~20 blocks) and the end. After a re-anchor or an image strip the measured prefix shrinks to wherever bytes still agree; previews place nothing and do not advance the measurement. 3. Per-message shaping happens BEFORE pricing: `maxMessageTokens` is applied here (a port of the autobiographical strategy's truncateContent, same marker) rather than merely advertised, and the autobiographical live-image policy (maxLiveImages / imageStripDepthTokens / maxLiveImageBytes, same placeholder, same defaults) runs as a post-pass. If the newest message alone still exceeds the usable budget the strategy refuses with OverBudgetError (the framework's over-budget breaker handles it) instead of emitting a request the provider rejects; dry runs report rather than throw. The docstring's clamp claim is narrowed to what actually applies. Tests: branch switch without re-init, persisted-anchor-past-head, hard budget refuse + truncate-and-fit, image placeholder policy, marker placement on first compile / append / no-growth / re-anchor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review on anima-research#54 (Anarchid, majors 4 and 6): - auxiliaryMessageViews: an entry that resolves to the manager's own message slot is refused at open() (it would merge every message twice and silently double token accounting); a slot listed twice merges once. - viewFilter's contract now states what it is not: not retroactive over derived state a strategy persisted earlier, and not a confidentiality boundary — a summary written while its sources were visible stays loadable and selectable after a predicate hides them, exactly as removeMessage behaves. Excision of already-folded content is a chronicle-branch operation. Predicates keyed on ingestion-time stamps that never change (tune-out's) are unaffected: such a message is hidden from its first instant and can never have been summarized. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review on anima-research#54 (Anarchid, major 5 + the regression asked for under 4): - Live path: with a filter installed, hidden messages never reach the summarizer's payloads or the compiled context, the coverage invariants hold through a full compression drain, and the store keeps everything. This is tune-out's situation (stamp and store are one write). - Contract pin: a summary written while its sources were visible still compiles after a later filter hides those sources. Pinned deliberately so the documented non-retroactivity is a visible contract; a future invalidation feature would update this test knowingly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review on anima-research#54 (slimepriestess, nonblocking 1 and 3): merged views re-sort per read, so nothing hot should hang off one; filterMessageStoreView's getFrom indexes the filtered world. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Follow-up round — every item addressed, one commit per theme so each reads on its own. Suite on the branch: 709 pass / 0 fail (was 698; +11 tests). Major 1 — stale anchor across branch switches → Major 2 — no cache marker → Major 3 — oversized newest message → Major 4 — persisted summaries bypass the filter → Major 5 — autobio under an active filter → Minor 6 — aux slot aliasing → slimepriestess nonblocking 1 & 3 → Changelog fragment updated for the new strategy behaviors. #115's tune-out suites pass against this build (22/22); its own follow-up is on that PR. |
slimepriestess
left a comment
There was a problem hiding this comment.
APPROVE at head ef11162. Both of my blockers cleared and verified: the framing test is text again (the NUL became its two-character escape — 5307→5308 bytes, GitHub renders it now), and the rebase landed clean (MERGEABLE, choke point intact on current main).
I also read the new machinery from the other review's majors, since it's substantial:
- Branch-scoped state:
syncToBranch()at every entry point, past-head validation resetting to 0-with-warning rather than an empty window — and the detail that makes it correct rather than merely defensive:prevCacheKeysresets alongside the anchor, because the previous compile happened on another timeline. The raw-switchBranchtest is exactly the right pin for the host-side bypass case. - Cache markers: keys computed AFTER shaping and stripping (so they name actual wire bytes), ≤2 under the shared ≤3 contract, dry runs neither place nor advance measurement, no-growth dedups to end-only via the Set. Reading the placement doctrine out of kv-unified while correctly declining the solver itself was the right altitude.
- Shaping before pricing: truncation port priced at cap+marker, images priced honestly pre-strip (conservative direction),
OverBudgetErrorwith a stage label for the breaker. The surrogate-safe slice port carried its reason with it.
One scope note, not a request: the autobiographical pricing change (live pair-emission sites through recallPairCost, provider-measured s.tokens flooring the estimate) is the fix for the plan-vs-actual class I flagged on #85 and #81 — welcome, correct in direction, and carrying its own tests plus the 78k-token receipt. But it's real behavior change to the resident strategy riding a groundwork PR; worth a line in the merge commit so future archaeology doesn't have to discover it inside "strategy-view composition."
For the record, not for this PR: my grep-the-branch suggestion turned up two more raw NULs — src/adaptive/kv-cache-sim.ts (renders binary on GitHub right now) and one deep in autobiographical.ts — both pre-existing on main from the kv-unified landing, not yours; your new code used a space. I'll fix those separately.
Suite verified locally: 709/709. The prepare-hook disposition (stays for the git-ref install, leaves at the published-version flip) is fine now that it's stated. Good round — six majors and three nonblockings, each either fixed with a test or declined with a reason, which is the correct ratio of both.
All four majors and the minor were addressed at f11d7d0/38f9531/c8b4bb1 and re-verified on current main (759/759); slimepriestess approved at head. Dismissing the stale request so the PR can merge.
What
The context-manager primitives for tune-out (anima-research/agent-framework#77), per the design comment there. Three additions, each inert until configured — zero behavior change for existing callers:
ContextManagerConfig.viewFilter— strategy-facing exclusion predicate, applied at the one choke point (strategyMessageView()) through which compile, preview, render-stats, and the tick/onNewMessageStrategyContextall obtain their message view. An excluded message is simply not part of the strategy's world — on both sides of every invariant.Why here and nowhere else: exclusion at emission level fails twice —
assertFullCoveragejudges entries against the whole view and throws on any uncovered message, andselect()runsrebuildChunks(store)first, so emission-excluded content still flows into L1 compression (and from there into L2/L3 folds). The view boundary closes both. Excluded messages remain in the store:getMessage/getAllMessages/queryare deliberately unfiltered (the tune-out backlog must stay dumpable/auditable).ContextManagerConfig.auxiliaryMessageViews— additional message slots merged read-only into the strategy view, interleaved by chroniclesequence(branch-global across slots — allocated under the store's single write lock — so the merged timeline is deterministic and append-only). Writes still target only the manager's own slot. This is the subconscious's merged (main + own) view from the issue. Freshness across instances comes for free:getAllInternalrevalidates against live chronicle state (count + last-item identity) before trusting its cache — pinned by a test.WindowedPassthroughStrategy— passthrough over a sequence-anchored window with coarse re-anchoring: on overflow the anchor jumps forward so the window is ~reAnchorFraction(default 0.5) of usable budget, then stays put until the next overflow. Between jumps, compiled output is a byte-stable prefix + pure appends. A naively sliding front (whatPassthroughStrategy.selectFromEnddoes) re-busts the KV prefix on nearly every compile — the exact pathology kv-stable folding solves for main agents, and this strategy's consumers get no solver. The anchor persists in a{ns}/windowed:anchorsnapshot slot (so it survives restarts and follows branches), andsetAnchor()is the external policy hook ("start of the oldest active tune-out").Compositors are exported standalone (
filterMessageStoreView,mergeMessageStoreViews) and unit-tested.Tests
test/view-composition.test.ts— 9 tests: compositor units (every read surface, cross-viewget, sequence interleaving); CM integration (filter excludes from compile but not the store; aux slot merges by sequence; live cross-instance reads after cache priming); windowed strategy (anchor honored + append-stability; overflow re-anchors once and holds through further compiles and modest growth; anchor survives reopen). Full suite: 456 pass / 0 fail.What this deliberately does not do
No tune-out semantics, no stamping, no subconscious lifecycle — that's the agent-framework half (single PR per antra's preference), which consumes these three primitives plus the ingestion stamp. The
viewFilterdocstring pins the one contract that half must honor: the predicate must be deterministic per message (ingestion-stamped metadata, not derived state), or prefix stability and strategy bookkeeping suffer.🤖 Generated with Claude Code