Repository navigation
Conversation
Backfill after a session restore onto a stash lacking the originals wrote the restored previews under the originals' keys, so retrieve_context served the preview as the original. store_message now skips offload placeholders, and retrieve_context reports a placeholder entry as no longer available. Fixes strands-agents#5620
Seeding an agent with already-offloaded messages stashed the previews under the originals' keys, overwriting the originals in a shared stash. storeMessage now skips offload placeholders, and retrieve_context reports a placeholder entry as no longer available. Refs strands-agents#5620
…ed content
Offload strategies leave an OffloadedBlock { ref, preview } where they removed
content, instead of a text block carrying a " [ref: ...]" token. ModelProxy
renders it to the same text the model receives today, so agent.messages holds
a block whose type says it is a placeholder: the stash never stores it, a
strategy never offloads it twice, and readers can tell it from tool output.
A tool result is referenced by its toolUseId; retrieve_context resolves that
to every stored item. The regex check on legacy text placeholders stays for
sessions saved before this block existed.
…context manager A message whose text was offloaded still counts as that role's text turn: the role text targets, and the agentic mode's turn boundary and role filters, accept the placeholder as well as the text itself. The retrieval tool's examples use the bare toolUseId reference a placeholder now carries.
Message-level offload (utilization-based drop, summarize, and the emergency cut) stores the removed messages as one stash entry and leaves an OffloadedBlock referencing it where it used to leave plain text. The marker is no longer stashed as content or offloaded again, and retrieve_context returns the removed messages, searching their text and tool-result text. The retrieval tool's skip set is derived from the history at store time instead of accumulated in a mutable set on the ContextManager.
…sink reads messages directly Every model provider renders the block as the text the model receives, so a history that reaches a provider without the proxy (native token counting, or a direct model call) keeps the preview and reference instead of dropping the block. Telemetry records the same text, and the A2A adapter converts it to a text part.
The reference on OffloadedBlock is optional, so a placeholder is typed whether or not the original was stashed; without a stash it renders as its preview alone. Strategies no longer fall back to plain text markers, so a placeholder is never offloaded twice even when the stash is disabled. Truncation returns the preview and the kept items instead of a rebuilt tool result, and the strategy builds the placeholder from them directly. The "[Stashed]" suffix is gone: the preview already opens with the truncation header, and the block renders its own reference token.
retrieve_context deserializes each entry with the same readers that load a session, then works on block instances instead of inspecting serialized JSON for text, json, image, or content keys. A stash key has one format and one parser, so resolving an id to its entries no longer slices the key by hand.
Message-level drop, summarize, and the emergency cut share _removeMessages: splice with tool pairs, leave the marker, restore alternation. The agentic mode uses the offload strategies' isTextContent instead of its own copy, and retrieve_context drops a content-type check that could not fail.
A placeholder is an OffloadedBlock, so the stash and retrieve_context no longer inspect text for a " [ref: ...]" token. Sessions saved before the block existed hold plain text placeholders; the context manager is experimental, so they are treated as content from here on.
…dits A block is stored on arrival under its position, but message-level removals merge same-role neighbours, which moves the later message's blocks to new positions under the earlier message's tracking id. A later per-block offload then emitted a reference nothing was stored under. The stash now stores the block under the reference it emits when no entry is there yet, so the reference resolves regardless of how the history was edited in between.
…ther content Message.withContent and ToolResultBlock.withContent replace the hand-rolled rebuilds in the offload strategies, the marker insertion, the alternation repair, and the model proxy. The strategies now keep a tool result's error, and a merged message keeps the earlier message's metadata instead of only its pin. The two truncation strategies share one elided-messages marker, and the context-manager tests share one set of message and context helpers.
…ool result The stash no longer skips the retrieval tool's own results. They were skipped to save storing a copy of content already stashed, which cost a scan of the whole history on every message; the offload strategies already leave those results in place, so the copy is never referenced and the skip bought only storage.
The stash key format is covered by its own round-trip tests, storing plain text is covered by the store and retrieve tests, and the seeded-preview test that keeps a stashed original covers the empty-stash case too.
The full-content and search paths are their own functions, and the content block docs name the offloaded placeholder among the block kinds.
…story edits, and the strategy OffloadTargetMatcher resolves a target once and answers whether a block, tool result, or message is covered, replacing four free functions that each took the target and its parsed filters as separate arguments. The pair-safe removal, marker insertion, and alternation repair move to history-edits.ts as pure functions over the message list. Per-block and message-level eligibility share one path: the target's oldest matches, with preserveRecent of zero keeping all.
The strategy pipeline of each preset is a table entry; the constructor default and ContextManager.from read it instead of spelling the pipelines out.
retrieve_context returns a tool result's items as the blocks they are, so media reaches the model as media and only the text is cut to the result budget; before, a result mixing an image and text came back as one JSON blob that the cut could empty of its text. A reference now lists only its own keys instead of the whole stash, and ensureStashed checks for existing entries by listing rather than reading and parsing them, for every item of a tool result rather than the first. A later strategy may act on an already offloaded tool result: its reference is the toolUseId, which survives any reshaping of the result. Results of retrieve_context are left out of the stash again, found through the call in the preceding message rather than a scan of the history. The routing classifier reads an offloaded block as its text, and an unknown preset name is rejected even when it matches an Object.prototype key.
The placeholder is a TextBlock whose text is the preview followed by the reference, serialized as a text block with an offloaded field. Models, providers, telemetry, the A2A adapter, and readers of agent.messages handle it as the text it renders as, so the per-provider cases and the proxy's rendering pass go; a reader without the class, including an older SDK restoring a session, loads it as plain text. SDK code that must tell a placeholder from content checks instanceof OffloadedBlock, which the stash write path, the offload exclusion, and the truncation split already did.
…eld on TextBlock A placeholder is a TextBlock whose optional `offloaded` field holds the preview and stash reference, the shape 0022 uses for providerData. Readers find it where they already look, under the textBlock discriminant; SDK code checks the field through isOffloaded. The OffloadedBlock class and its subclass form go, so the block's instance and serialized form are the same shape.
…rimming "Trim" is the term the conversation manager already uses for removing whole messages while keeping tool pairs intact, and it sets the module apart from methods/truncate.ts, which shortens a block.
…and resolve ids before keys ensureStashed leaves a tool result alone when it already holds a placeholder, so a strategy acting on a seeded preview over an empty stash cannot write the preview as the original; retrieve_context refuses to serve a stored placeholder in any case. A reference is resolved as an id before an exact key, so a toolUseId that ends like a key is not shadowed by another result's item. The result budget leaves out the textual items after the one it cuts instead of replacing each with a marker.
…tadata The placeholder data moves from a top-level `offloaded` field to `metadata.offloaded`, mirroring `Message.metadata`: a bag of facts about the block that is never sent to model providers. `providerData` stays top-level because the provider reads it back.
lizradway
added a commit
to lizradway/harness-sdk
that referenced
this pull request
Oct 29, 2026
…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
Member
Author
|
Superseded by #5325, which now includes the typed offload placeholders from this PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Human Overview
Description
The context manager left offloaded content behind as a plain text block ending in
[ref: …], and everything that needed to recognise a placeholder (the stash write path, the strategies,retrieve_context, truncation) matched that text with a regex. #5620 was one consequence: a preview that looks like tool output gets stashed as the original. #5626 patched that one path with another regex. This PR makes the placeholder a typed fact instead, so the context manager checks a field rather than a string. Every place that used to inspect text goes through oneisOffloadedguard.A placeholder is still a
TextBlock, so providers, chat UIs, the tracer, A2A, and the session format see exactly the text they see today. The fact that it is a placeholder, and where the original lives, rides onmetadata.offloaded, mirroringMessage.metadata: it is for readers and the SDK, never sent to the model. This follows Murat's suggestion in the design thread to keep it undertextBlockas metadata rather than a new block type, which would break consumers that switch ontype.Public API Changes
TextBlockgains an optionalmetadata?: TextBlockMetadata, serialized only when set.TextBlockMetadataandOffloadedBlockDataare exported;isOffloadedis@internal. Old SDKs deserialising a new session read the block as plain text.Behaviour changes inside the experimental context manager:
retrieve_contextreturns the removed messages. Previously those messages 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. The stash stores under the reference it emits, so a block moved to a new position still resolves. Previously that reference pointed at nothing.main.Related Issues
Related: #5620, #5626. Design context:
team/designs/0022(providerDataonTextBlock) and #5587 (InjectedBlock).Documentation PR
Docs for the context manager will follow in a separate PR once the Python port lands.
Type of Change
New feature
Testing
Ran the full TS unit suite (201 files, 5,650 tests),
eslint,prettier --check, andtype-check. Exercised the stash, strategies, andretrieve_contextend to end throughContextManagerandAgentwith a mock model, including the #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. Not exercised against a live model provider.hatch run prepare(TS-only change;strands-py/is untouched relative tomain)Additional Details
Why not message metadata, and what else moved
Why block-level rather than
Message.metadata: a message can hold several placeholders, andrepairAlternationmoves blocks between messages when it merges same-role neighbours. Positional indices in message metadata go stale on the first merge; a field on the block travels with it.Structural changes, no behaviour intent:
OffloadTargetMatcher(strategies/offload/target.ts) owns target andtool::/!filter matching;message-trimming.tsholds the message-level removal and alternation repair that drop, summarize, and the emergency cut share; one_removeMessagespath replaces three; aPRESET_STRATEGIEStable replaces the preset branch ladder.Message.withContent/withMetadataandToolResultBlock.withContentreplace hand-rolled copies.Known limitations, deliberately left:
Stash.clear()is unused in both SDKs and kept for parity. Agentic-mode removals leave no marker.storeRemovedduplicates media bytes already stashed per block.Branch note: the branch forks from
mainbefore #5626 merged, so the diff currently shows #5626's Python commits. Those files are byte-identical tomainand drop out whenmainis merged in; that merge conflicts in the five TS files #5626 and this PR both touch, so it will be a separate commit.Follow-ups: Python port of
metadata.offloadedfor parity; docs PR.Checklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.