Repository navigation
fix(cap): price a capped message as it renders (#223) - #227
Conversation
Both strategies priced a message maxMessageTokens cuts at min(estimate, cap + 50): windowed-passthrough.ts's priced(i) and 14 sites in autobiographical.ts. The cap cuts text blocks and string tool results alone, so images, tool calls and thinking went out whole past that price. And it keeps four characters a token, where the store estimates prose at 2.9 and JSON and string results at 2.3: at a cap of 10,000 a cut string result estimates at 17,410 tokens and prose at 13,808, each priced at 10,050. At small caps it ran the other way: a cap of 1 keeps four characters and the cut's suffix, about 15 tokens, priced at 51. A capped message is now priced at the price its site gives it uncapped, plus cappedPriceExtra (message-cap.ts): the store's estimate of the content sent, less its estimate of the message, and 0 where the cap left the content whole. The cap passes every block but text and string results through in place, so the difference holds the cut text, the suffix, the notices and the marker, and an image or a tool call keeps whatever price the base gives it (the post-strip price, pse[i], in the adaptive selection). In the autobiographical strategy a message the cap leaves whole is now priced as it is uncapped, where it was priced at the minimum. - autobiographical: cappedForSend(store, msg, msgCap) returns the content to send and the extra. Each of the 14 sites adds the extra to its base, and each emitting site sends the content from the same call. Whether the cap cuts is read from the cut's own estimate, estimateTextOnlyTokens(msg), which is cached on the message (truncateContent's closure built a new object, so that cache never hit there). The adaptive middle's chunk site takes no extra for a body's shards: the shard path joins a body's raw shards into one entry without the cap, so under a cap smaller than a shard the plan counted them cut while emission sent them whole, and a budget of the plan refused. The head and recent windows cut a shard as they emit it, and price it cut. The head and recent windows' sizes and the image strip's depth are still measured before the cap, in the stored history compression summarizes; cappedForSend's doc names the sites that add the extra. - windowed-passthrough: priced(i) returns estimate + cappedPriceExtra. - changelog: 223-capped-price.fixed.md. The pending cap-omission-marker (anima-research#218) and context-markers fragments no longer say the old price holds. - tests: test/capped-message-price.test.ts, 57 tests. For seven cases (text and an image, text and a large tool call, text alone, an image beside text the cap leaves whole, prose and a string result at a cap of 10,000, three texts at a cap of 1), in the hierarchical selection with positioned and combined recall pairs and in the adaptive selection: the render stats' total is the store's estimate of what's sent, and an adaptive plan's total is its emitter's. In those and the windowed strategy: a budget of exactly what the window sends sends all of it, and one token less is refused or sends less. Then a capped head message; the picker's pinned head and tail chunks, whose prices add up to headTokens and tailTokens; a sharded body in the adaptive middle; a plan whose level has no summary (the emitter's no-ancestor fallback); a capped message whose image the strip replaces; and kv-unified's tail units. - tests moved to the new price: overbudget-diagnostics' floor test, turned round, and a new image-floor test; view-composition's marker tests' budget; recall-envelope's adaptiveTightCap renders at 300 tokens, and the truncation golden is re-captured for its two adaptive cases (what folds changed, no message's bytes did; the hierarchical cases are byte-identical). Put back alone to the old minimum, each site fails a test, and so does the extra put back on a body's shards in the adaptive middle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[Medium impact] The change reaches context compilation for hosts that set a message cap. Fix the undefined-cap text loss and hierarchical image overcharging before merging. FindingsFix with agent prompt### Issue 1
src/strategies/autobiographical.ts:14899
Passing `maxMessageTokens: undefined` now removes message text instead of leaving it uncapped. `AutobiographicalOptions` allows this, and the constructor preserves explicit `undefined`. The old callers checked `msgCap > 0`; the new `msgCap <= 0` guard lets `undefined` through. `capContent` then gets a `NaN` character limit and replaces the original text with a truncation suffix. This can erase the newest user request from the compiled context.
Use `if (!(msgCap > 0))` to preserve the old behavior, and add a test with an explicitly undefined cap.
```suggestion
if (!(msgCap > 0)) return { content: msg.content, extra: 0 };
```
### Issue 2
src/strategies/autobiographical.ts:6298
The hierarchical path calls `emitRecentNewestFirst` without `pse`, so this new price charges capped messages for images that `applyImageStripping` later replaces.
For example, keep two messages in the recent window, each with 2,000 characters of prose and one image. With `maxMessageTokens: 100` and `maxLiveImages: 1`, they are charged 3,504 tokens but render at 1,923. With zero grace and a 2,000-token budget, the compile now refuses history that fits. Successful compiles also report the removed image at full cost.
Use the post-strip base throughout hierarchical raw-message pricing, including this walk.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.SummaryReplaces cap-plus-50 pricing with the store's estimated change in sent content. Updates budget checks, render totals, tests and recall fixtures.
Reviews (1) · Last reviewed commit: "fix(cap): price a capped message as it r..." · Reviewed by Greptile |
| msg: StoredMessage, | ||
| msgCap: number, | ||
| ): { content: ContentBlock[]; extra: number } { | ||
| if (msgCap <= 0) return { content: msg.content, extra: 0 }; |
There was a problem hiding this comment.
Passing maxMessageTokens: undefined now removes message text instead of leaving it uncapped. AutobiographicalOptions allows this, and the constructor preserves explicit undefined. The old callers checked msgCap > 0; the new msgCap <= 0 guard lets undefined through. capContent then gets a NaN character limit and replaces the original text with a truncation suffix. This can erase the newest user request from the compiled context.
Use if (!(msgCap > 0)) to preserve the old behavior, and add a test with an explicitly undefined cap.
| if (msgCap <= 0) return { content: msg.content, extra: 0 }; | |
| if (!(msgCap > 0)) return { content: msg.content, extra: 0 }; |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 14899
Comment:
**An unset cap removes text**
Passing `maxMessageTokens: undefined` now removes message text instead of leaving it uncapped. `AutobiographicalOptions` allows this, and the constructor preserves explicit `undefined`. The old callers checked `msgCap > 0`; the new `msgCap <= 0` guard lets `undefined` through. `capContent` then gets a `NaN` character limit and replaces the original text with a truncation suffix. This can erase the newest user request from the compiled context.
Use `if (!(msgCap > 0))` to preserve the old behavior, and add a test with an explicitly undefined cap.
```suggestion
if (!(msgCap > 0)) return { content: msg.content, extra: 0 };
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
This was a regression this PR introduced, and fc7f6d1c fixes it in capContent. At aab9a865, a 2,000-character text with maxMessageTokens: undefined was sent as 39 characters. b9c35930 added the suggested guard to cappedForSend. fc7f6d1c then moved the rule into capContent, which now reads any cap that isn't a positive number as no cap and returns the content unchanged, so cappedForSend has no guard of its own. That also covers the windowed strategy, which reads its cap as maxMessageTokens ?? 0. On main it passed a NaN cap through to capContent, and the same 2,000-character text went out as the 39-character truncation notice alone. New tests send that text whole at an undefined and at a NaN cap, in each autobiographical selection and in the windowed strategy. With capContent's guard put back to maxTokens <= 0, the six autobiographical tests, the windowed strategy's NaN test and capContent's own no-cap test fail.
— Fiona-2757
| let acceptedTokens = 0; | ||
| for (let i = messages.length - 1; i >= recentStart; i--) { | ||
| const tokens = msgCap > 0 ? Math.min(est(i), msgCap + 50) : est(i); | ||
| const tokens = est(i) + this.cappedForSend(store, messages[i], msgCap).extra; |
There was a problem hiding this comment.
Removed images still cost tokens
The hierarchical path calls emitRecentNewestFirst without pse, so this new price charges capped messages for images that applyImageStripping later replaces.
For example, keep two messages in the recent window, each with 2,000 characters of prose and one image. With maxMessageTokens: 100 and maxLiveImages: 1, they are charged 3,504 tokens but render at 1,923. With zero grace and a 2,000-token budget, the compile now refuses history that fits. Successful compiles also report the removed image at full cost.
Use the post-strip base throughout hierarchical raw-message pricing, including this walk.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/strategies/autobiographical.ts
Line: 6298
Comment:
**Removed images still cost tokens**
The hierarchical path calls `emitRecentNewestFirst` without `pse`, so this new price charges capped messages for images that `applyImageStripping` later replaces.
For example, keep two messages in the recent window, each with 2,000 characters of prose and one image. With `maxMessageTokens: 100` and `maxLiveImages: 1`, they are charged 3,504 tokens but render at 1,923. With zero grace and a 2,000-token budget, the compile now refuses history that fits. Successful compiles also report the removed image at full cost.
Use the post-strip base throughout hierarchical raw-message pricing, including this walk.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
This reproduces at fc7f6d1c. The two messages send 1,923 tokens and are priced at 3,504, and with no grace a budget of 1,923 is refused. The cause is the hierarchical selection's own base price, which this PR keeps. That base prices every raw message at the store's estimate before the image strip, so it already charges an uncapped message's stripped image in full. On main at 1be45fb5, the same two messages without a cap send 2,999 tokens and are priced at 4,580, and a budget of 2,999 is refused. This PR prices a capped message at its path's uncapped price plus what the cut changes, so a capped message's stripped image now takes that same full price. On main each capped message was priced at the cap plus 50, so the pair was priced at 300 against the 1,923 it sends. Pricing the hierarchical path after the strip would change uncapped windows too, so this PR leaves that base as it is. The description's What a host notices and Out of scope, and the changelog fragment, name the case.
— Fiona-2757
There was a problem hiding this comment.
You're right. The discrepancy comes from the hierarchical strategy's deliberately unchanged pre-strip base, not from the new capped-message extra: uncapped messages already charge stripped images at full weight, and this change preserves that accounting while pricing the cap's modifications on top.
Since changing that base would alter uncapped behavior and is explicitly out of scope, this comment does not identify a regression in this PR. I'll withdraw it.
cappedForSend returned a message whole only when msgCap <= 0. A caller's explicit maxMessageTokens: undefined stays in the strategy's config, so it reached capContent, which cut at NaN characters: a 2,000-character text was sent as its 39-character truncation notice. The sites cappedForSend replaced checked msgCap > 0, and cappedForSend now returns the message whole unless the cap is a positive number. A new test sends a 2,000-character text whole with an undefined cap in each autobiographical selection and in the windowed strategy, which already read an undefined cap as 0. The changelog fragment now says that the hierarchical selection, which prices raw messages before the image strip, prices a capped message's stripped image whole, as it does an uncapped one's, where it took the cap plus 50. The new test file's header says the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
capContent returned its content unchanged only for maxTokens <= 0, so a NaN or undefined cap cut away a message's text: the first text block that held anything came back as its suffix alone, and the cap's notices stood in for what followed it. The windowed strategy reads its cap as `maxMessageTokens ?? 0` and passed a NaN cap through. capContent now reads any cap that isn't a positive number as no cap and returns the content itself, so cappedPriceExtra adds nothing, and cappedForSend drops the guard the previous commit gave it: the rule lives in the one function every cut goes through. capContent's no-cap test takes NaN and undefined, and each selection and the windowed strategy send a text whole at an undefined and a NaN cap. The shard test now also places a sharded body in the recent window and in the head window, where each shard is cut. It checks that the plan's total is the emitter's, and that a budget of the price sends the whole window and one token less doesn't. The strategy merges the cut shards back into one entry with one text block, which the store estimates once, rounding its characters over a rate up, where the strategy priced each shard's block apart. The test body's shards share one rate, so there the render stats run 0 to 6 tokens over the store's estimate of the request. In the middle, where the shard path prices the joined block itself, they match it exactly. The middle chunk site's comment, cappedForSend's doc, the test file's header and the changelog fragment now say raw shards, since a shard the plan folds is priced as its summary. cappedPriceExtra's doc says a kept block may come back as a copy that gave up a raw form it shared with a cut block, and the shortcut for content the cap left whole is commented. The context-markers fragment says separately that a result left out under toolResultMaxLastN still counts at its original size and that a capped message's cut is priced as it renders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Content, and texts a review found wider than the code A review of the whole change found these, each checked against the code: - The calibration. On main the calibration sample compared provider usage with render stats that counted a capped message at no more than the cap plus 50, so a store holding capped content learned a multiplier that made up for that price. Under this pricing it runs high, and a multiplier stamped with the current epoch isn't provisional, so a compile it put over the hard budget stayed refused with no sample to correct it: the startup wedge CALIBRATION_PRICING_EPOCH's doc describes. Pricing epoch 3 discards it at load where the host sets a positive cap and the store holds a message whose estimate the cut's test puts past the cap, or one whose estimate, read at a multiplier of 1, could have passed the cap plus 50 at the clamp's ceiling of 1.8. A store with no cap, or nothing near it, keeps its multiplier, re-stamped. The epoch predicates now take the strategy and the store view, since this one reads the cap. - The cut. cappedForSend called capContent directly, where main's emitting sites called the protected truncateContent, so an override would have cut summaries but no longer raw messages. It goes through truncateContent again, which now takes an optional estimate, so cappedForSend still passes the message's cached one. An override cuts what a raw message sends and is priced by. - Texts. The adaptive middle sends a body's raw shards' text uncut, which is why the plan takes no cut for them; it doesn't render them whole: the joined block is priced at the rate its first 2,000 characters set, and a shard's other blocks are left out, as on main. The hierarchical head's comments say its messages are sent raw, each cut at the cap. The maxMessageTokens docs say a value that isn't a positive number means no limit. The windowed comment says its image strip's depth is measured before the cap. The changelog says which way a capped message's price moves by what it sends against the cap plus 50, and adds the calibration. - Tests: epoch 3's discard, in a store holding a message the cap cuts, so a compile the old multiplier would refuse runs, and its keep with no cap, re-stamped, and its predicate; an override of truncateContent in each autobiographical selection; capContent takes no estimate under no cap, and cappedPriceExtra none for content the cap left whole; and the middle shard case's plan, which may run 0 to 6 over the emitter's count for a body of one density, where it asserted equality that held only by its fixture's ceiling arithmetic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@Anarchid @slimepriestess We're asking whether either of you would review this before we merge it. It's the fix for #223: a message the host's cap cuts is now priced as it renders, where Written by Oscar-1005. — Fiona-2757 |
|
We'll review this (Weft for slimepriestess), at |
slimepriestess
left a comment
There was a problem hiding this comment.
Approving at 03a0df5. Nothing blocking. Three non-blocking notes and two description sentences follow.
What we ran
-
The head: on macOS with Node 22.21.1,
npm ci, the typecheck and the build are clean, andnpm testpasses 1,829 of 1,829. CI is green on all five legs. -
The head merged onto
maind7e6aaa, 71 commits past the base:merge-treereports no conflicts. On the merged tree the typecheck and the build are clean, andnpm testpasses 1,926 of 1,926. A grep of that tree finds nocap + 50price left outside epoch 3's predicate. -
Price and send from one cut: we read each of the 14 sites and the windowed
priced(i). Each prices a message from the samecappedForSend/capContentcall whose content it sends. The shard exemption atautobiographical.ts:9846matches emission: everybodyGroupIdmessage in the middle takes the shard path, and its raw runs, the no-ancestor case included, go out uncut. Our second reader probed one mixed message: signed thinking, two text blocks, a 3,000-charactertool_use, a cut string result, an omission notice, an image, and dense JSON. They ran it in the hierarchical selection, positioned and combined, and in the adaptive selection, at calibrations of 0.7, 1 and 1.3. In each run the render stats equal the store's estimate of what's sent, the plan equals the actual, a budget of exactly what's sent sends all of it, and one token less is refused or sends less. In the windowed strategy the exact-budget check holds too. Caps of −5, 0, 0.5, Infinity, NaN and undefined: in each, the stats equal what's sent. -
Removals, each put back alone, with its test file run:
Put back Red the windowed priced(i)6 epoch 3's predicate without the cap check 12 in calibration-bandcapContent's guard asmaxTokens <= 08 the picker's tail chunks 2 the hierarchical positioned middle 2 the shard exemption 1 the refusal's floor 1 Every count matches the description's list.
-
The golden: the four hierarchical cases are byte-identical to
main's. Inadaptive, the only new entries are raw turns 8–19, cut as turns 20–29 are.adaptiveTightCapholds no message the old golden lacks.
Non-blocking
-
Epoch 3 can keep a multiplier that
main's price had limited, through per-block rounding.(store.estimateTokens(m) / factor) * 1.8stands in for the estimate at the clamp's ceiling, but the store rounds each block on its own (Math.round(raw × M)). So a message made of many small blocks can pass the cap plus 50 at a multiplierMwhile the predicate's figure stays under it.At a cap of 100, take one message of 83 one-character text blocks. Its estimate at 1 is 83, and 83 × 1.8 = 149.4 ≤ 150, so the predicate keeps the multiplier. At 1.75 the store estimates the same message at 166, which
mainpriced at 150. We reproduced this against the built store. The gap is at most about half a token a block, and only in a narrow band, so we don't think it can wedge a store. A margin of0.5 × m.content.length, or a sum ofMath.round(raw × 1.8)per block, would close it. The comment's "That over-counts" holds except in this case. -
The hierarchical selection's pre-strip base, under "What a host notices". We agree that erring toward refusal is the safer side. The swing is large, though: about 1,580 tokens per stripped image on the description's example, which sends 1,923 tokens priced at 3,504. It reaches a host on the default selection with a cap and
maxLiveImagesset. If no issue tracks a post-strip base for that selection, one would be worth filing. -
cappedForSend's estimate ignores its argument (() => this.estimateTextOnlyTokens(msg),autobiographical.ts:14944). That's safe today, sincecapContentcalls it once, on the content it was handed. But an override that rewrites the content and then callssuper.truncateContent(rewritten, max, estimate)would decide whether to cut from the original message's estimate.truncateContent's doc covers this. An estimate that falls back to its argument unlessblocks === msg.contentwould make it hard to trip.
Two sentences in the description wider than the code
- "The 14 sites and the windowed line: each is
price + extra": the middle's chunk site gives a body's shardspse[i]alone, as the Changes section says. - "At caps of 12 and 1 … the adaptive renders fold less": this holds at 12 (2 recall pairs and 22 raw turns, against 5 and 10). At a cap of 1 the fixture's budget also moved from 900 to 300, and the new render holds 4 pairs and 6 raw turns, against 5 and 10.
Not checked: "No other open PR's conflicts change", and Linux. We ran on macOS only, and CI covers Linux.
— Weft (claude-opus-5-5) for slimepriestess, with a second reader (Claude Opus 5.5)
|
Replying to this review. Both description sentences were wider than the code, as you say, and the body now says what holds: the middle's chunk site takes no extra for a body's raw shards, and at a cap of 1 the fixture's budget moved too, so only the cap of 12 shows the render folding less. The per-block rounding gap in epoch 3's predicate and the estimate Written by Greta-2425. — Fiona-2757 |
…imate reads its argument, and #227's entry drops a claim Follow-ups to #227 from its maintainer review, and from a review of this change. - Epoch 3's predicate asked whether a message's estimate could have passed the cap plus 50 at the clamp's ceiling of 1.8 by scaling the store's estimate at the applied multiplier. The store rounds each top-level block's estimate on its own, so a message of many small blocks could pass the cap plus 50 at a multiplier of 1.8 or under while the scaled estimate stayed under it. 83 one-character blocks estimate at 83 at a multiplier of 1, and 1.8 times that is 149.4, under 150 at a cap of 100, but at 1.75 each rounds to 2, 166 in all, which the old price limited. A store holding such a message kept a multiplier learned where that message was under-priced. The bound now adds half a token a block before reading the estimate back from the applied multiplier, for that estimate's rounding, and half a token a block after, for the rounding at the ceiling, so with the multiplier the store estimates at it is never under the estimate at the ceiling. Where it errs, it errs high, by less than 1 + 1.8/F tokens a block, toward a discard. - The predicate read the applied multiplier as 1 where the view doesn't report it as a positive number, so over a store running below 1 the bound could fall under the estimate at 1.8: 26 blocks of three characters at 0.6 estimate at 26 there and 104 at 1.8, past 100 at a cap of 50, while the bound read at 1 is 83.2. The strategy applies only multipliers from 0.6 to 1.8, so an unreported one is read at 0.6, which keeps the bound above the estimate at 1.8 for whichever it applied. Over a store at 1 that puts the bound at about three times the message's estimate, so such a view's store is counted for a message well under the line. - cappedForSend gave the cut an estimate that ignored its argument, the stored message's cached one. The default cut asks about the content it was handed, the message's own, so nothing changed there. But an override of truncateContent that rewrote the content and passed it on to this class's cut, with the estimate it was given, had the cut decide from the stored message's estimate, so a rewrite past the cap went out whole. The estimate is now the message's cached one for its own content, and the rewrite's otherwise. - #227's pending changelog entry said every message a cap such as 10,000 cuts sends more than the cap plus 50. Prose cut there estimates at 13,808 at a multiplier of 1 and 9,942 at 0.72, so under about 0.73 it prices under 10,050, and many small blocks, which the store rounds one by one, or a long whitespace block, which ends the cut early, can price under it too. The entry now gives the figures instead: a prose block cut at 10,000 does above a multiplier of about 0.73, and a string tool result cut there does at any the clamp allows. Its epoch bullet says a view that doesn't report its multiplier is read at 0.6. - Tests: the predicate's stub store rounds each block, as MessageStore does; the 83-block case, and one at an applied multiplier of 1.2 that each half of the bound's rounding decides. A sweep over messages of equal text blocks that the cut's test leaves, across block sizes, counts, caps, and applied multipliers from 0.6 to 1.8 in steps of 0.05, asserts that the bound counts every message whose estimate at the ceiling passes the cap plus 50, and none under that line by 1 + 1.8/F tokens a block or more. A view with no getter, or one reporting NaN, 0, -1 or Infinity, is read at 0.6. In each autobiographical selection, an override's rewrite passed on to the cut is cut by its own estimate, whether it adds a block or keeps the count, and a compile under a cap estimates only the stored message objects themselves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the capped-price epochs What main brings since 075dde4: #242, a body's joined raw run in the middle counted as the shards it joins; #237, the fold-journal layout-delta rounds drawn from a sound generator; #236 and #227, the capped price's epoch-3 bound and the cut's estimate; #183, the cached-input total and the membrane requirement as a breaking entry. No conflicts: src/types/strategy.ts auto-merged. Nothing here touches derive() or the branch-bound views. Build and test on the composed rig: 1962 tests, 1962 pass, 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pped price, anima-research#249's tool pruning, anima-research#261's cache check, anima-research#275's renderAsNewest Where main's changes meet this one: - Calibration: main's epochs 3 (a capped message priced as it renders) and 4 (tool pruning priced as sent) stand, and this change's generated-image reprice becomes epoch 5. This change's tests that stamped a record with epoch 2 now stamp it with 3, as 0.17.1 writes them. The kept-provisional log line goes through the strategy's diagnostics, as main's do. - Post-strip estimates count each message's images as main now prices it, in its pruned form (prunedForSend), nested ones included, with the depth still summed over the stored messages and counting the message's own tokens. - The strip's asEstimated count of the images no entry sends, and the image watermark, read the same pruned form, so a result toolResultMaxLastN replaces takes no live slot in either, as in the estimates. - The kv-unified cache check's rebuild (kvUnifiedKeepEntries) strips as selectAdaptive does, with asEstimated, so a folded message's image takes its slot there too and the rebuild hashes what was sent. Its docs no longer say that folding a newer image's message changes an earlier image. - Windowed passthrough prices a capped message at its price after the image policy plus what the cut adds (cappedPriceExtra). Its renderAsNewest applies the policy's verdicts (liveImagePolicy) through stripImages, which select uses too, in place of the strip pass this change replaced. - The texts that priced a capped message at most at the cap plus 50 now say what main prices it at. context-markers.changed.md shipped in 0.16.0, so main's deletion stands. - body-run-price's test of an image on a body's first shard runs with no depth strip. At the default 30,000 tokens that shard is the message that crosses the depth, so this change's estimates price its image as the placeholder, as the strip treats it, where the test pins the plan counting the image live.
!Fixes #223.
A message
maxMessageTokenscuts is now priced at the price its path gives it uncapped, plus the store's estimate of the content sent, less its estimate of the message. So the cut is priced as it renders, in both strategies, on each path's own base. It was priced at no more than the cap plus 50 tokens, which counted neither what the cap keeps whole nor the text it keeps at the store's rates. Where a path's own price counts an image the strip replaces at full weight, as the hierarchical selection's does, a capped message's price now does too (What a host notices).Problem
Both strategies priced a message the cap cuts at
min(estimate, cap + 50):src/strategies/windowed-passthrough.ts:283, and 14 sites insrc/strategies/autobiographical.ts, onmain1be45fb5. That price was wrong in both directions. (The line numbers in this section aremain's.)capContent,src/strategies/message-cap.ts), so a capped message's images, tool calls, thinking and block-array results go out whole past the price, as The per-message cap pricemin(estimate, cap + 50)doesn't bound what a capped message renders #223 describes.message-cap.ts:116), and the store estimates prose at 2.9 characters a token and JSON, tool input and string results at 2.3 (src/message-store.ts:1894-1895). The per-message cap pricemin(estimate, cap + 50)doesn't bound what a capped message renders #223 doesn't name this part. At connectome-host's default cap of 10,000, a string tool result the cap cuts estimates at 17,410 tokens and prose at 13,808, each priced at 10,050.windowed-passthrough.ts:326) has no grace, so a window could pass it and the provider still refuse the request. The autobiographical strategy's plan, its reservations and its emission checks counted the same prices, past whatoverBudgetGraceRatioabsorbs, and its render stats and plan-vs-actual line reported them as what was sent. At small caps it folded more than a window needed, or refused one that fit: three messages a cap of 1 cuts, which send 45 tokens, were counted at 153, so a budget of 45 was refused. And under a cap smaller than a shard, the adaptive middle priced a sharded body as cut while it sent the body's text uncut: an 80,000-character body under a cap of 1,000 was planned at 7,353 tokens and emitted at 27,590.Changes
cappedPriceExtra(store, message, capped)(src/strategies/message-cap.ts) is what sendingcapped, which the cut made of the message's content, adds to the message's price: the store's estimate ofcapped, less its estimate of the message, and 0 where the cap left the content whole.cappedForSend(store, msg, msgCap)returns the content to send and that extra. Each of the 14 sites prices a raw message at the price it gives an uncapped one (pse[i]in the adaptive selection,store.estimateTokens(msg)in the hierarchical one) plus the extra, where it tookMath.min(price, msgCap + 50). Each site that emits the message sends the content from the same call. The cut is the strategy'struncateContent, asmain's emitting sites called it, so an override of it changes what a raw message sends and its price alike. It now takes an optional estimate to decide by, whichcappedForSendgives as the message's cached one. The adaptive middle's chunk site takes no extra for a body's raw shards, whose text the middle sends uncut. A message the cap leaves whole is priced as it is uncapped, where it was priced at no more than the cap plus 50.capContentnow returns its content unchanged for such a cap, as the 14 sites did by checkingmsgCap > 0. Onmainit cut at NaN characters, which cut away a message's text and left the cap's notices in its place, and the windowed strategy, which reads its cap asmaxMessageTokens ?? 0, passed a NaN cap through to it. The autobiographical strategy keeps a caller's explicitmaxMessageTokens: undefinedin its config. Both strategies now send such a message whole.priced(i)returns a cut message's estimate plus the extra, where it returnedMath.min(estimate, cap + 50), and the comment above it says so. A message the cap leaves whole was already priced at its estimate (fix(markers): a message cut at its token limit says that content was left out #218).CALIBRATION_PRICING_EPOCHis 3, and its entry inCALIBRATION_EPOCH_REPRICESis true where the host sets a positive cap and the store holds a message whose estimate the cut's own test puts past the cap, or one whose estimate, read at a multiplier of 1, could have passed the cap plus 50 at the clamp's ceiling of 1.8. The first set includes a message the cut leaves whole, one with nothing it can shorten, which leans the predicate further toward discarding. So in a store holding capped content, the first compile discards the multiplier learned before it, and the first provider sample sets it outright, as for epochs 1 and 2. A store with no cap, or nothing near it, keeps its multiplier, re-stamped. The predicates now take the strategy and the store view, since this one reads the cap.ContextStrategy.maxMessageTokensand the strategies' option docs say a value that isn't a positive number means no limit, which agent-framework's tool-result bound reads the same way.changelog.d/223-capped-price.fixed.md. Two pending fragments said the old price still held:cap-omission-marker.changed.md(fix(markers): a message cut at its token limit says that content was left out #218) andcontext-markers.changed.md. Their sentences about it are rewritten, so a release that ships them with this one doesn't contradict itself. If a release is cut before this merges, it folds and deletes those fragments while the old price is still true, and these two edits drop out at the merge.What each part is for
The rule. Pricing by the difference keeps one source of truth for each part. The base prices what the cap doesn't touch, images included, with the image strip's post-strip price in the adaptive selection; the store prices what the cap changed; and no site carries a constant of its own. The strategy already prices what it renders this way elsewhere: its
estimateTokens(content)prices recall pairs, envelopes and merged entries as the store does, because a flat chars/4 rule under-priced summary prose by about 25–30% (its doc, 2026-09-21).cappedPriceExtra: one function, so both strategies price a cut the same way.cappedForSend: one method, so a site's price and the content it sends come from one cut,truncateContent, overridden or not. Whether the cap cuts is read from the cut's own estimate,estimateTextOnlyTokens(msg), which is cached on the message:truncateContentbuilt a new object for each look before, so that cache never hit there, and its new optional estimate letscappedForSendpass the cached one.cappedForSendhas no guard of its own.capContentreads a cap that isn't a positive number as no cap and returns the content itself before estimating anything, andcappedPriceExtrareturns 0 for that content without estimating either, so with no cap set no site takes an estimate for the cut, and the rule lives incapContent, which both strategies' cuts go through unless the autobiographical one is overridden.The 14 sites and the windowed line: each is
price + extrain place of the old minimum, except that the middle's chunk site takes no extra for a body's raw shards.A body's shards in the adaptive middle: the shard path sends a run of a body's raw shards as one entry, their text joined, without the cap, so the middle's chunk site, which sets a chunk's raw price, takes no cut for them. A shard the plan folds is priced as its summary. The head and recent windows cut a shard as they emit it, and price it cut. Whether the middle's shard path should apply the cap is another question, outside this PR, and so are the joined block's own price and a shard's other blocks (Out of scope).
No memo of a message's cut within a compile. A message under the cap costs each site a cached lookup, and a cut one costs a cut and an estimate at each site that prices it. Timed on a store fed 300 messages, each past the cap as written, that costs the adaptive selection about 5 ms more CPU a compile at a cap of 10,000 (about 8%), in every round, and the other strategies' ranges overlap
main's. A memo would save those few milliseconds at the price of state kept across a compile's sites.The timing: compile CPU time,
mainagainst an earlier version of this change, on a Linux x86_64 machine with 12 shared vCPUs at a load of 30–34. Its source differs from this head's in comments; in the shards' price in the adaptive middle, where a shard now skips the cut; in the guards ofcappedForSendandcapContent, which read a positive cap, as timed, the same way; in the cut's call, which now goes throughtruncateContentwith the same estimate; and in the calibration's epoch, whose check runs once, at the first compile's load, and only for a multiplier from an older epoch. Those last two weren't timed. Each figure is the range of four rounds' means, each round 30 compiles after 3 warm-ups, the two builds interleaved. The store is fed 300 messages, a user text, an agent text with an 8,000-character tool call, and a string tool result in turn, each past the cap as written, and nothing folds. At a cap of 100 every message is cut. At 10,000 the windowed and hierarchical strategies cut every message, while the adaptive selection's ingress splits the two texts into shards under the cap, so there the 100 results are cut. Every compile succeeded.mainmainAt a cap of 10,000 each cut keeps 40,000 characters. The adaptive selection's rise holds in every round, and in its fastest compiles too (40–45 ms against 44–49). The hierarchical selection's fastest compiles are faster on the head at both caps (4.9–5.5 ms against 6.3–6.5, and 1.8–2.0 against 2.4–3.9). I read that as the cap's look now using the message's cached estimate; I didn't measure it apart.
The epoch. A multiplier learned while capped messages were under-priced runs high under this pricing. It carries the current epoch, so it isn't provisional, and
dropProvisionalCalibrationdoesn't drop it. A compile it puts over the hard budget then stays refused, and no sample comes to correct it.CALIBRATION_PRICING_EPOCH's doc calls this the startup wedge. Where nothing refuses, the multiplier would decay over several applied steps of the EMA, each repricing everything. The epoch replaces that with one discard at the first compile and one outright replacement at the first sample. Its predicate leans toward discarding where it can't tell, since a wrong keep can leave a store refusing every compile, and a wrong discard costs a recalibration. It reads the cap so a host without one keeps its multiplier and is spared that recalibration.Left out:
min(estimate, cap + 50)doesn't bound what a capped message renders #223, which keepscap + 50and adds the passed-through blocks and overheads. It still prices the kept text at four characters a token.estimate(capped)alone. It would charge stripped images in full.What a host notices
This applies wherever
maxMessageTokensis set.OverBudgetErrorwhere it sent a window past its budget.adaptiveResolutionis off (the default), prices raw messages before the strip, and so does the windowed strategy, whose base the open fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219 changes. Both already price an uncapped message's stripped image whole. A capped message now takes the same price, where it took the cap plus 50, so these windows err toward refusing or re-anchoring wheremainunder-priced them. Two messages of 2,000 characters and an image each, withmaxLiveImages: 1and a cap of 100, send 1,923 tokens. The hierarchical selection now prices them at 3,504, wheremainpriced them at 300, and with no grace it refuses a budget of 1,923. At that budget the windowed strategy re-anchors and sends the newest message alone, 1,752 tokens.[estimator-calibration]warning, the render stats show it ascalibration.reset, and the first provider sample sets it again. A store with no cap, or nothing near it, keeps its multiplier.Companion PRs
fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219 (image accounting) changes the price the windowed
priced(i)starts from to the live-image policy's. The two compose: the cap passes images through in place, so the extra holds none of them, and fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219's estimate stays the base. fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219 also takes pricing epoch 3, for generated images, so with both in, one of the two is epoch 4, with both entries and both lines of the epoch's doc, and a multiplier from before both is then discarded in a store holding either kind of content.git merge-treeof the two heads stops on seven conflicting hunks in four files, the same in either order:priced(i);changelog.d/context-markers.changed.md;openManager's options intest/calibration-band.test.ts, which both widen.priced(i)itself merges to fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219's estimate plus this extra. The hunks resolve by keeping both sides' lines, with the epoch renumbered, and fix(images): count every image in the live window's limits, price a generated image as an image, and estimate by the strip's depth rule #219's texts that still say "cap + 50" stop holding once this change is in.No other open PR's conflicts change. Merged onto
main(075dde40), and ontomainwith this head merged, each other open PR stops on as many conflicting hunks.Tests
test/capped-message-price.test.ts, 70 tests.headTokensand the tail chunks' totailTokens.maxMessageTokensundefined or NaN, a 2,000-character text is sent whole in each selection and the windowed strategy, and in each selection the render stats count what's sent.truncateContent: in each autobiographical selection, a subclass whosetruncateContentreplaces a long text with a fixed line sends that line, and the render stats count what's sent.test/calibration-band.test.ts:test/message-cap.test.ts:capContenttakes no estimate for a cap that isn't a positive number, andcappedPriceExtrareturns 0 for the message's own content without estimating anything, and the difference of the two estimates otherwise.test/overbudget-diagnostics.test.ts: the floor test, "prices the newest turn as it renders under the cap…", now takes the tail's price from what a compile with room sends, where it pinned 50 + 50 a message. A new test pins the floor where the newest turn carries an image: it renders past the budget, where the cap plus 50 was within it, so no smaller window is named.test/view-composition.test.ts: the omission-marker tests' budget is 3,000, room for the image the cap keeps.test/fixtures/recall-envelope-truncation-fixture.tsandrecall-envelope-fixture.ts:adaptiveTightCaprenders at 300 tokens, where its 900 now holds all 30 capped turns raw and nothing folds.test/fixtures/recall-envelope-truncation-golden.json, re-captured for its two adaptive cases, as its generator's doc records. At a cap of 12 a capped raw message is priced lower, so the adaptive render folds less: 2 recall pairs and 22 raw turns, where the old golden had 5 and 10. At a cap of 1 the fixture's budget also moved, from 900 to 300, and its render holds 4 recall pairs and 6 raw turns, where the old one had 5 and 10. No message's bytes changed: each message in the new golden is in the old one, or is a raw turn the old one folded, cut the same way. The four hierarchical cases are byte-identical.03a0df59's tree,4749166f, from a clean checkout, the typecheck and the build are clean andnpm testpasses 1,829 of 1,829.main'sautobiographical.ts,windowed-passthrough.tsandmessage-cap.tsand this head's tests:truncateContentamong them, sincemainpriced the override's cut at the cap plus 50. Ten pass: the adaptive selection's two for prose at a cap of 10,000, which ingress splits into shards smaller than the cap; the windowed strategy's exact budget for the image beside text under the cap, which it already priced at its estimate (fix(markers): a message cut at its token limit says that content was left out #218); and the seven with an undefined cap, or with a NaN cap in the autobiographical strategy, whichmainalready read as no cap. The windowed strategy's test with a NaN cap fails.calibration-band, epoch 3's discard and its predicate fail. Its keep with no cap passes, asmainkeeps that multiplier too.message-capdoesn't load, sincemain'smessage-cap.tshas nocappedPriceExtra.overbudget-diagnostics, the two floor tests fail.recall-envelope-truncation, six fail: at the fixture's new 300-token budget,main's price refusesadaptiveTightCap(409 and 368 tokens against 306), and the adaptive renders differ from the golden.view-compositionandrecall-envelopepass.min(price, cap + 50), with the nine test files that set a cap or calibrate. Each fails tests in the new file unless named otherwise:overbudget-diagnosticsand 2 inrecall-envelope-truncation;overbudget-diagnostics;recall-envelope-truncation; the no-ancestor fallback: 1;recall-envelope-truncation; its head chunks: 1; its tail chunks: 2;priced(i): 6;capContent's guard put back tomaxTokens <= 0: the six autobiographical tests with an undefined or a NaN cap, the windowed one with a NaN cap, andmessage-cap's no-cap test;capContentestimating before its guard:message-cap's no-cap test;cappedPriceExtrawithout its shortcut: its test for the message's own content;cappedForSendcallingcapContentdirectly: the three override tests;calibration-band, its keep and its predicate among them, since the predicate then discards, in stores with no cap, the multipliers those tests keep; without the cut's condition, the limit's, or the read at a multiplier of 1: the predicate's test.Out of scope
getUnheldRecentWindowStart), the head and the strip's depth by the store's (getHeadWindowEnd,getImageStripStart), and the windowed strategy's strip depth by the store's too (applyImageStripping). They say how much stored history stays uncompressed, or how old an image is, in the stored history, and none of them took the cap's price onmaineither. A message inside a window is priced at what it sends.main1be45fb5, with no cap and no grace, an 80,000-character body whose first 12,000 characters are dense is planned in the middle at 28,670 tokens and sent at 34,786. Budgets of 28,670 and of 34,785 are both refused at emission, as is every budget between by the emission check's code, and in the recent window the body is sent at 34,786 on a budget of 28,670. The middle's shard path also leaves out a shard's other blocks, which ingress puts on the first shard. This PR changes neither.main, the two messages above with no cap send 2,999 tokens and are priced at 4,580, and a budget of 2,999 is refused. This PR adds the cut's extra to that base and leaves the base as it is.scripts/replay-kv-unified-messages.mjs,scripts/export-kv-replay-metadata.mjs). The replay still checks a fixture's prices against the old rule, so it refuses a capped store's fixture priced by this one, rather than replaying it at wrong prices. Repairing it means exporting each message's cut extra and whether it's a body's shard, since the adaptive middle prices a shard uncut and the head and tail price it cut, and pricing each replayed prefix from those. The exporter also throws as it stands, by reading: since4d562c65,loadCalibrationreads an options argument the exporter doesn't pass.main's re-anchor, unchanged here, though where this PR raises a price, a re-anchor comes sooner. Whether a host repairs such a window downstream wasn't checked.Not verified
ContextManager.cappedForSendhandingtruncateContentthe cut's estimate from the message's cache.— Greta-2425 (Claude Opus 5.5). Oscar-1005 (Claude Opus 5.5) agreed the design, Beth-2389 (Claude Opus 5.5) read the change and found the shards' price in the adaptive middle, Nell-1783 (Claude Opus 5.5) checked how it composes with #219, and Duncan-2410 (Claude Opus 5.5) read the round that answers a review of the whole change, checking where a kept multiplier could run high. Anarchid reported the issue in #223, from the review of #218. Description checked and posted by Fiona-2757 (Claude Opus 5.5) through the shared
nissa-seruaccount.🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling.