Repository navigation
Conversation
blockThreshold replaces threshold, "N%" shares are accepted alongside counts, the target and blockThreshold pick messages vs blocks, Offload.* returns a builder that needs .when(), truncate gains a middle-out message form and skips its own previews, and summarize handles each contiguous run separately.
|
@strandly-the-agent pls review |
|
Assessment: Comment Clean, well-motivated refactor. The target/ Review notes
Nice work locking down the semantics before graduation — the alternatives-considered section made the design easy to follow. |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Changes requested — the granularity rule itself reads well and matches the Python PR, but routing non-"*" target + utilization to the per-block path exposes one regression: _applyPerBlock has no last-message guard, so Offload.drop('toolResults').when({ utilization: '70%' }) replaces the tool result the model is about to answer with [Dropped] (verified end-to-end with a real Agent; same config on main left it intact). One 🔴, three 🟡 inline. This also changes the public Offload contract ahead of graduation and carries no api/needs-review label.
✅ Verified, questions, reading order, appendix
Verified at c92baa17
npx vitest run src/context-manager→ 454 passed. Deltafddda555a..c92baa17is error text + a comment only; all findings re-run at the new head.- Repros (uploaded to the Strandly portal):
e2e-live-message.test.ts(realAgent+MockMessageModel, 5 cases incl. amainbaseline),adversarial-strategies.test.ts(19 cases),truncate-convergence.test.ts. - Python #5324 parity: names,
'N%'/decimal aliases, percent regex, error texts,_acts_on_messagesrule and builder-not-a-strategy all line up.
Questions (non-blocking)
utilization: 80now means 80 tokens and fires on the first turn; onmaina value >1 effectively never fired. The(0,1]-share / integer-count overload makes the'80%'→80slip silent. Worth rejecting1 < utilization < 100with a "did you mean'80%'" error?- Message-level markers (
[Dropped: N messages],[... N messages elided ...],[Summarized: N messages …]) carry no stash refs, so the model can't retrieve what was removed even when a stash is on. Intended for now? blockThresholdon'*'silently flips the strategy from message mode to block mode; a non-'*'target can never act on messages. Is that the contract you want to graduate? (The github-actions review above also asked for an API pass.)
Reading order: base.ts _actsOnMessages / constructor → _applyPerBlock (🔴) → truncate.ts _applyPerMessage/_selectMiddle → summarize.ts _applyPerMessage/_summarizeRun → index.ts builder → tests.
Appendix — non-blocking (5)
- ⚪
Percent(base.ts:44) appears in the exportedOffloadConditionstype but isn't exported fromsrc/index.ts, so users can't name it. - ⚪
preserveRecent: '0%'parses fine thendrop('*')throws "set utilization or preserveRecent" (base.ts:467-471) — confusing for a value the type invites. - ⚪
resolveStrategiescrashes withCannot read properties of undefined (reading 'apply')on a nullish entry instead of the friendly error (presets.ts:73); TS types prevent it, JS callers don't. - ⚪
drop('*')re-drops its own[Dropped: N]marker on later passes and resets N to 1 — pre-existing onmain, same root cause as the truncate convergence comment inline. - Percent
blockThresholdbypassing the numeric convergence guard — already raised by the github-actions review and addressed by the comment inc92baa17; not re-raised.
Restores the condition name main uses, so existing .when({ threshold }) calls keep working.
…xt window A count above the model's context window can never be reached, so the strategy would only fire on overflow.
|
@strandly-the-agent pls review |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Approving at a58a30de — all three fixes hold up against the original repros, and the threshold rename matches #5324. Still needed before merge: the api/needs-review label (public Offload contract changed pre-graduation) and a PR-body refresh — it still says blockThreshold and shows threshold → blockThreshold as the API change, which is now backwards.
Previous findings:
- 🔴 per-block +
utilizationtouched the live message → fixed (1f9e95e):_applyPerBlockpops the last message unless the strategy runs eagerly. Re-ran the gateddrop('toolResults')anddrop('*') + thresholdcases: older tool result dropped, live one intact. - 🟡 summarize run throw left partial mutation → fixed (
9ea9fa1): per-run catch, logs, continues;repairAlternationstill runs when anything acted. - 🟡 middle-out truncate re-elided its own marker → fixed (
f0833f4): marker carriers are excluded fromeligible. Re-ran the churn case: pass 2 returnsfalse, later passes append a second marker and the11 messages elidedcount survives. - 🟡 tool pair straddling the head/tail boundary → withdrawn to ⚪ per your question; replied on the thread.
✅ Verified / notes
npx vitest run src/context-manager→ 472 passed ata58a30de(was 454; new tests cover the live-message skip, the failed run, the marker stop, and the utilization window check).- Round-2 repro + log uploaded to the portal (
followup-fix-verification.*). utilizationtoken count > context window now throws ininit()(so atinitAgent, not construction) — fine, just noting it's a runtime error on agent setup rather than at.when().drop('*')still re-drops its own[Dropped: N]marker on later passes — pre-existing onmain, not blocking; the same carrier-exclusion trick would fix it if you want parity with truncate.- Python #5324 also renamed back to
threshold; builder/error texts still aligned.
|
@strandly-the-agent can you review the api for any potential bugs or edge case? does this make sense with every way the api could be used? is this clear and documented in the comments? |
…arify percent thresholds
strandly-the-agent
left a comment
There was a problem hiding this comment.
API pass at a58a30de — the shape holds up; two 🟡 edges and the docs lag the rules. Answering your three questions:
- Bugs / edge cases: no 🔴. Two 🟡 inline: block mode never protects message 0 (message mode does), and this PR adds two new routes into block mode (
'*'+threshold,utilization-gated) so e.g.drop('userText').when({ utilization: '70%' })now drops the task prompt; and theconditionsobject isn't validated at runtime — a typo'd key orundefinedsilently turns a gated strategy into an eager / unguarded one. - Does every combination make sense: yes, with the rule as stated — I ran the full {drop, truncate, summarize} × target × conditions × value-form matrix (construction +
apply()). Every invalid value throws with a message that names the fix. The cells worth a second look are in the fold. - Is it documented: the interface-level JSDoc states the rule, but the property docs,
TruncateConfig.previewTokens, and theOffloadexamples don't carry it — a user hoveringutilizationcan't see that1is 100% and2is two tokens,previewTokensdoesn't say it's the head+tail budget in message mode, and there's no example of the two modes you'd reach by accident (eager{}and utilization-gated block). Suggestions inline.
Still pending from before: api/needs-review label and the PR body (blockThreshold).
Matrix cells, what I verified, appendix
Matrix — fine but worth knowing
truncate(target).when({ threshold: N })throws whenN <= previewTokens(good), but'N%'isn't checked:truncate(['tool::bash']).when({ threshold: '1%' })on a 100k window = 1,000 tokens = the default preview, so matching blocks are counted and left unchanged with no log. Either resolve-and-check ininit()(window is known there, like_checkUtilizationFits) or say so onpreviewTokens.drop('*').when({ threshold: 0 })is accepted and is the eager "drop every block of every message" that.when({})on'*'rejects — same request, different spelling.'0%'is rejected. Consider rejecting0, or document "threshold: 0matches every block".- Omitting
utilizationdoesn't mean "never": with onlypreserveRecentthe strategy runs before every model call; with neither it runs on every arriving message including the one the model is about to answer (drop('userText').when({})drops the user's prompt). Both are by design; neither is said onOffloadConditions. preserveRecent: 1.0is the count1(JS has no float1.0);0.99keeps all matches (ceil) while'100%'is rejected — harmless inconsistency.utilizationtoken count on a model withoutcontextWindowLimitmeasures against the default window silently.summarize('*')with no aux model warns and returnsfalseforever — fine, since a realAgentalways hasauxModel.
Verified: npx vitest run src/context-manager 472 passed; matrix tests (api-matrix.test.ts, 16 tests; construction-matrix.test.ts, 23 cells) + outputs uploaded to the portal. Python #5324 docstrings compared: Python repeats the value forms on every when() parameter and its when() is keyword-only (typo = TypeError); TS is better on OffloadTarget and the pipeline-ordering note.
Appendix — non-blocking / pre-existing (5)
- ⚪
OffloadTargetsays arrays are "namespaced withtool::" but the prefix is optional, and mixingtool::awith!tool::bkeeps only the includes with alogger.warn(base.ts:418-421).validateTargetis the natural place to make that a construction error now that everything else throws. - ⚪
'*'doc says "tool results + text blocks" but block mode also offloads media blocks to[Offloaded: ~N tokens]. - ⚪ Preset doc:
staleToolCleanupis "all but the newest 5 tool-result messages", not "older than 5 messages";proactiveSummarizationomitspreserveRecent: 0.7. Pre-existing text. - ⚪ Error
threshold (1000) must be greater than previewTokens (1000) to ensure truncation convergessays why, not what to do — append "raise threshold or lower previewTokens". - ⚪ One strategy instance shared by two agents: the eager hook reads
this._stashfrom whicheverinit()ran last (base.ts:499-509). Pre-existing; a "one instance per agent" line onOffloadStrategyBuilderwould do.
…target Target "*" now always acts on whole messages, and threshold measures each message's size. Other targets act on blocks, and threshold measures each block. A new "media" target selects image, document, video, and audio blocks outside tool results, which "*" with a threshold used to reach.
…with a threshold Drop removes messages over the threshold and summarize condenses them, so truncate previews their blocks rather than eliding them, which with a threshold behaved the same as drop.
| ? resolveStrategies(config.strategies) | ||
| : [ | ||
| Offload.truncate('toolResults', { previewTokens: TRUNCATE_PREVIEW_TOKENS }).when({ | ||
| threshold: AUTO_TRUNCATE_THRESHOLD, |
There was a problem hiding this comment.
old name faild loud right, it is expected.
For target '*', it doesn't measure blocks, The name is confusing in one of its two modes
| readonly name = 'offload:truncate' | ||
|
|
||
| private readonly _truncateConfig: TruncateConfig | ||
| private readonly _previewTokens: number |
There was a problem hiding this comment.
This one is from AI:
truncate('*') with blockThreshold can silently do nothing. It uses two sizes. blockThreshold picks the messages, then previewTokens decides which blocks inside them get cut (_shortenEach passes this._previewTokens to _transformBlocks). I tested a 6,000-token assistant message made of ten 600-token text blocks, with blockThreshold: 1500 and previewTokens: 1000. The message was selected, but apply() returned false and the message stayed at 6,000 tokens. The PR description says it "shortens each large message in place", which doesn't hold when a message is large because it has many small blocks.
…d conditions redesign Merges strands-agents#5657 (offload placeholders as typed TextBlock metadata) into the conditions redesign and replaces the text-matching logic both relied on: - truncate recognizes offloaded content with isOffloaded instead of the "[Truncated:" prefix, and its own elision markers by their typed removedMessages count instead of a regex - every strategy builds placeholders with offloadPlaceholder; the "[Stashed]" suffix and string[] stash refs are gone - drop, truncate middle-out, each summarize run and emergency truncation share one removal path, so messages they remove are stashed and retrievable; apply() restores alternation once afterwards - targets move into OffloadTargetMatcher (target.ts) and conditions into ResolvedConditions (conditions.ts), leaving base.ts the strategy flow - EmergencyTruncateStrategy is a plain ContextStrategy
Resolves the strands-agents#5626 conflicts in favor of the typed offload placeholder check, which replaces the TS ref regex guard.
|
Issue: This commit adds new public types to the core message model — Suggestion: List |
|
Assessment: Approve Re-reviewed at Review notes
Nice modularization — the single removal path and typed placeholders are a clear improvement over the previous string-matching approach. |
|
Assessment: Approve Re-reviewed at
The rest of the merge pulls |
…e and record the offload method
Placeholder construction and every marker string now live in context-manager/placeholder.ts.
OffloadedBlockData gains a required `method` ('drop' | 'truncate' | 'summarize'), so code and
tests identify a placeholder by its metadata instead of matching its text.
|
Assessment: Approve Re-reviewed at What I verified
Note: I couldn't run Nicely done — folding message-level removal onto one path and moving the wording into a single module reads much more maintainably. |
… derive isMediaBlock from the media kinds
|
Assessment: Approve Re-reviewed at What I verified
Optional (non-blocking): the Note: I couldn't run |
…ceholder-text callback
…s untouched items kept
… of deriving them in getters
|
@strandly-the-agent pls review |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Round 7 at 5bd28c3 — the typed-placeholder + stash/retrieval work holds up under attack (14 adversarial repros green, incl. a real-Agent eager-truncate → retrieve_context round trip), but two of the new decisions need an API call before graduation, so this is a comment, not an approval.
.when()optional again reverses round 1's fix:Offload.drop('toolResults')now destroys the first tool result on arrival and only then logs its one-time warn (base.ts:279, after the replacement). Minimum fix is to warn ininit(); the real question is whether eagerdropof unread text should be allowed at all when'media'throws for the identical hazard.TextBlock.metadata.offloadedputs a context-manager concept on a stable core type, andOffloadMethod/OffloadedBlockData/TextBlockMetadataare exported from the stablesrc/index.tswith no@experimentaltag — while everything else here is experimental. At minimum tag them; also consider acustomextension point to matchMessageMetadata.- Python #5324 has none of this: no typed placeholders, no new stash key scheme, no retrieval rewrite, still documents
tu_abc123_0refs. Either port before #5205 or split the TS-only parts out.
Smaller: [Dropped] discards the size it computed (inline). api/needs-review label still absent. I'm not repeating JackYPCOnline's open threads.
✅ Verified, passes, appendix
Verified at 5bd28c3: npx vitest run src/context-manager src/types agent.context-manager.test.ts → 577 passed; eslint + prettier clean; type-check errors only in untouched integ files (steering, notebook). Delta reviewed: 181260c..5bd28c3 (12 commits; whole PR is now 40 files, +3112/−1198).
Passes: adversarial (completed, 14 repros) and API bar-raiser (completed) — reports + repro tests uploaded to the portal as r7-*. The correctness/tests pass timed out before writing anything; I covered its key items myself: provider leakage (five adapters have tests; Bedrock builds { text: block.text } so metadata can't leak by construction), warn placement, exports, Python parity.
Attacked and fine: stash round trip through a real Agent (original 11,489 chars returned byte-for-byte); multi-block tool results keep index order; drop-after-truncate doesn't stash the placeholder and still resolves the original; removed-<trackingId> ids retrieve dropped messages and pattern search works across them; prefix/underscore ids (tool_1 vs tool_10 vs tool) all resolve correctly; .when() copies are independent (no shared stash or warn state); toJSON/fromJSON and repairAlternation preserve metadata; '*' with no conditions throws at init(); blockThreshold: '100%' and old utilization key rejected with the expected text.
Appendix — non-blocking (5)
- ⚪ Sessions persisted before this PR carry
[ref: 1_toolu_abc_0]; the tool now answers "not found" with no format hint — fine for afeat!, but worth a line in the PR body. - ⚪
[ref: …]sits after the preview tail, up topreviewTokensaway from the[Truncated: …]header the model scans; folding it into the header would help. - ⚪ A tool result that contains an image is dropped as
[Dropped], not as an image (mediaKindis evaluated on theToolResultBlock). - ⚪ Pre-existing: a tool result reusing an earlier
toolUseIdoverwrites that stash entry (stash.tskey${toolUseId}_${blockIndex}), so both placeholders resolve to the later result. - ⚪
removedMessagescounts only the current pass when a later pass removes a marker's carrier (chain is still retrievable); design doc 0015 still saysthreshold/utilization/skipRecent.
| acted = true | ||
| } | ||
| } | ||
| if (acted) this._warnUnconditional() |
There was a problem hiding this comment.
🟡 The unconditioned-strategy warn fires after the damage. This is the only call to _warnUnconditional(), and it runs after message.content[blockIndex] = replacement — so for Offload.drop('toolResults') the first tool result the model was about to read is already [Dropped] when the log line appears (api-pass probe: warn calls after init: 0 | after first arrival: 1 | block dropped: true). Round 1 made .when() mandatory to close exactly this; 'media' still throws for the same hazard (base.ts:146-149).
Suggestion (minimum): warn in init() where _conditions.isEmpty and _runsEagerly are already known, before any history is touched:
init(agent: LocalAgent, stash?: Stash): void {
this._stash = stash
this._checkRunnable()
this._conditions.checkFits(agent)
if (!this._runsEagerly) return
this._warnUnconditional()
agent.addHook(MessageAddedEvent, …)Bigger question for the API pass: should eager drop of unread text (toolResults/assistantText/userText) throw like 'media' does, leaving toolResultErrors and tool arrays (where the target is the condition) as the eager-drop cases design 0015 §2.5 actually names? Python has the same post-hoc call (base.py:713-714), so whichever you pick should land in both.
| /** | ||
| * Offload method that replaced content with a placeholder. | ||
| */ | ||
| export type OffloadMethod = 'drop' | 'truncate' | 'summarize' |
There was a problem hiding this comment.
Stable core type now carries an experimental context-manager concept, exported stable. OffloadMethod, OffloadedBlockData and TextBlockMetadata have no @experimental tag and are exported from src/index.ts:70-72 under Message types, while every other context-manager symbol is under Context Manager (experimental) or experimental/index.ts. Once this ships, the shape of offloaded is 1.x-stable even though the strategies that write it aren't. Also, sibling MessageMetadata has custom?: Record<string, JSONValue> as its extension point; TextBlockMetadata has only offloaded, so the next feature needing block metadata has no door.
Minimum: tag the three @experimental and add custom for parity. Alternative worth a look: keep TextBlockMetadata { custom? } core-agnostic and move OffloadedBlockData + isOffloaded to context-manager/placeholder.ts, exported from experimental/index.ts (cost: the guard narrows via a cast).
| /** | |
| * Offload method that replaced content with a placeholder. | |
| */ | |
| export type OffloadMethod = 'drop' | 'truncate' | 'summarize' | |
| /** | |
| * Offload method that replaced content with a placeholder. | |
| * | |
| * @experimental | |
| */ | |
| export type OffloadMethod = 'drop' | 'truncate' | 'summarize' |
| logger.debug(`trackingId=<${message.trackingId}> | dropped block from context window`) | ||
| return new TextBlock(marker) | ||
| logger.debug(`trackingId=<${message.trackingId}>, block=<${block.type}> | dropped block from context window`) | ||
| const placeholder = this._placeholder(formatDropped(mediaKind(block)), stashRef) |
There was a problem hiding this comment.
🟡 [Dropped] tells the model nothing about what it lost. tokens is computed for every block (base.ts:268) and passed here as _tokens, then ignored; truncate and summarize both say ~N tokens. A model deciding whether to call retrieve_context has no size to go on, and a dropped tool result that contained an image reads as plain [Dropped] because mediaKind is evaluated on the ToolResultBlock, not its content.
Suggestion: formatDropped(tokens, mediaKind) → [Dropped: ~1,300 tokens] / [Dropped: image, …], mirroring formatOffloaded.
| const placeholder = this._placeholder(formatDropped(mediaKind(block)), stashRef) | |
| const placeholder = this._placeholder(formatDropped(tokens, mediaKind(block)), stashRef) |
(and rename the parameter from _tokens to tokens).
Human Overview
Description
ContextManager graduates in #5205, and after that the Offload
.when()conditions can only change in 2.0, so their meanings need to be right first. Onmain, settingutilizationsilently switches drop, truncate and summarize into whole-message mode. That causes three problems:thresholdpicks messages rather than blocks.utilizationdecides granularity as well as timing.The new rule is that the target chooses what a strategy acts on,
blockThresholdsets the minimum size to act on,contextUtilizationonly says when it fires, andpreserveRecentonly says what's protected. The conditions are renamed fromthresholdandutilizationso each name says what it measures. Target"*"always acts on whole messages, andblockThresholdthen measures each message. Every other target acts on blocks, andblockThresholdmeasures each block. WhenblockThresholdisn't set, there's no size check, so every matching message or block is acted on."*"(messages)[Dropped: N messages][Dropped]blockThreshold, keeps the head and tail withinpreviewTokensand elides the middle. With ablockThreshold, shortens each large message in place and leaves the newest one aloneValues follow one rule: a number counts in the condition's unit, and
"N%"is a share of the whole.blockThreshold: '5%'selects blocks larger than 5% of the context window. Decimals like0.85are not accepted, so each value has one meaning in every condition.This PR also folds in #5657, which makes an offload placeholder a typed fact instead of text the SDK has to pattern-match. Before, a placeholder was a plain text block ending in
[ref: …], and the stash, the strategies,retrieve_contextand truncation all recognised it with a regex, which is how #5620 happened. A placeholder is still aTextBlockwith the same text, so providers, UIs, the tracer, A2A and sessions see what they see today, butmetadata.offloadednow records the offload method that left it, the preview, the stash ref, and how many whole messages it stands for. Block metadata is never sent to the model. Building placeholders and wording every marker now happen in one module,context-manager/placeholder.ts, and the SDK identifies a placeholder by its metadata, never by matching its text. Landing both together lets message-level removal share one path in drop, summarize and the emergency cut, and lets the condition and target logic move out ofbase.tsintoconditions.tsandtarget.ts.Public API Changes
utilizationis renamedcontextUtilization, andthresholdis renamedblockThreshold. A"N%"blockThresholdis still a share of the context window, so'5%'selects content larger than 5% of the window.Offload.*returns anOffloadStrategy: aContextStrategywith a.when()that returns a copy under new conditions..when()is optional. Without conditions, a strategy acts on each matching block as it arrives, before the model reads it, and logs a warning the first time it replaces a block. TheOffloadStrategyBuildertype is removed."*"and"media"can't run without conditions, so a bareOffload.drop('*')throws when it's attached to an agent. With.when(), the same check runs when the strategy is built.strategiesthrows a named error instead of aTypeErrorfrom insideresolveStrategies."media"target matches image, document, video and audio blocks outside tool results. It needscontextUtilizationorpreserveRecent. Without either, the strategy would run on each message as it arrives and replace the media before the model's first call.['bash', '!python']acts on bash. A list whose excludes cancel every include (['bash', '!bash']) matches nothing and throws when it's built. Before, a mixed list logged a warning and ignored the excludes.contextUtilizationcounts conversation tokens and must be at least 1,000, so a missing%like80fails with a hint to write"80%". If it's larger than the model'scontextWindowLimit,initthrows. If the model has nocontextWindowLimit, it warns and skips that check; the count still fires at the right size, since it doesn't depend on the window..when()throws on an unknown condition name, such as a misspelledutilisation.TextBlockgains an optionalmetadata?: TextBlockMetadata, serialized only when set.TextBlockMetadata,OffloadedBlockData(method,preview,ref?,removedMessages?) andOffloadMethod('drop' | 'truncate' | 'summarize') are exported;isOffloadedis@internal. Older SDKs reading a new session see plain text.Behaviour changes for existing configs
contextUtilizationorpreserveRecent(0.85) throws an error whose message gives the"N%"to write. A number now always counts, so a value of1, which meant 100%, is rejected too. The presets and the'auto'and'agentic'context managers use"N%"."*"target pluscontextUtilizationnow acts on blocks.drop("*")andsummarize("*")now act on whole messages for every combination of conditions.truncate("*")withoutblockThresholdis now middle-out.blockThreshold, a strategy acts on blocks of any size. Truncate no longer defaults the threshold topreviewTokens, because its preview already leaves blocks that fit untouched.retrieve_contextcan return them. Onmainthey were gone.retrieve_contextreturns tool result items as blocks, so retrieved media reaches the model as media. Text is still cut to the result budget.repairAlternationmerges messages, because the stash stores under the ref it emits rather than the block's position.contextUtilizationorpreserveRecentleaves message 0 and the last message alone, as message mode already did.blockThresholdmust be at least 1, or a"N%"below"100%". AblockThresholdof 0 got past the"*"guard and acted on every message, and"100%"could never select anything.[Dropped: image, removed from context after it was viewed], not[Dropped]. On Opus 5 the bare marker read like media that never arrived, and the model took back answers it had already given correctly.[Offloaded: image, ~N tokens, removed from context after it was viewed].[Offloaded: …]writes its token count with commas (~2,525 tokens), as the other markers do.[Offloaded]and[Summarized: …]leave out the token count when it's 0, which is what the heuristic counter returns for media.blockThreshold > previewTokensfor every target, including"*", whoseblockThresholdwould otherwise select messages it can't shorten. A token threshold is checked when the strategy is built, and a"N%"threshold on init, against the context window.overflowProtectiondrops the oldest messages instead of truncating them.Risks / Callouts
insertMarkerappends the summary to the end of that message to keep its stash block indices stable.mainalready does the same for every marker. A pinned assistant message next to the gap also absorbs the marker. I'd fix this in a follow-up so the stash-ref trade-off gets its own review.blockThresholdinstead of never passing it, and its[Offloaded]marker leaves out the size. Estimating media size in the fallback counter is a separate follow-up.previewTokensanyway. It fires once per strategy instance, the first time the strategy replaces a block.storeRemovedstores a second copy of media bytes that were already stashed per block.metadata.offloadedis a follow-up.!in the commits is deliberate. The renames break configs onmainthat usethresholdorutilization; each now throws an unknown-condition error naming the accepted conditions.Related Issues
TypeScript counterpart of #5324; prerequisite for #5205. Supersedes #5657. Related: #5620, #5626. Design context:
team/designs/0022(providerDataonTextBlock) and #5587 (InjectedBlock).Documentation PR
Docs will follow in a separate PR.
Type of Change
Breaking change
Testing
npm run lint,format:check,type-check(afternpm run build, which the integ project needs), and the unit suite all pass (5,962 passed, 6 skipped).npm run complexitylabels itcomplexity/medium(max 19,collectRemovableWithPair). The highest-scoring function this PR adds is_transformBlocks, at 13.ContextManagerandAgentwith a mock model: the [BUG] ContextManager: after a session restore onto an empty stash, retrieve_context serves the truncated preview as the original #5620 seeding case over an empty stash and over a stash holding the original, history merges before a per-block offload, and retrieval of removed messages.Agentwith a mocked model: a bareOffload.drop('toolResults')dropped both tool results, kept their stash refs, and logged the warning once. A bareOffload.drop('*')orOffload.drop('media')threw, andOffload.drop('*').when({ contextUtilization: '80%' })ran.previewTokens: 50."*"strategy and after a media drop.Additional Details
Alternatives considered
contextUtilizationkeep choosing granularity, and reject the conflicting combinations instead. Rejected because one condition would still mean two things, and drop's message and block forms would still behave inconsistently."*"has a threshold. This was an earlier version of this PR. Rejected because"*"would then mean blocks or messages depending on another condition, the same coupling this PR removes fromutilization.previewTokensfor truncate. Rejected because it silently skipped media and small blocks, and truncate's preview already leaves content that fits unchanged..when()on every strategy. This was an earlier version of this PR. Rejected because a bare strategy likeOffload.truncate('toolResults')reads as what it does, and.when({})added nothing. The runtime warning covers the case where a strategy runs without conditions by mistake."N%". This was an earlier version of this PR. Rejected because a number then meant a share or a count depending on its size:1and1.0are the same number in JavaScript, socontextUtilizationof1meant 100% while2meant two tokens, and each condition needed its own parsing rules. Breaking experimental configs now is cheaper than graduating two spellings that can only be removed in 2.0.type.Message.metadata. Rejected because a message can hold several placeholders, andrepairAlternationmoves blocks between messages when it merges neighbours, so positional indices go stale on the first merge. A field on the block travels with it.Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
🤖 Generated with Claude Code