Skip to content

feat(context-ts): record offload placeholders as typed TextBlock metadata - #5657

Closed
lizradway wants to merge 23 commits into
strands-agents:mainfrom
lizradway:lizradway/offloaded-block
Closed

lizradway wants to merge 23 commits into
strands-agents:mainfrom
lizradway:lizradway/offloaded-block

Conversation

@lizradway

@lizradway lizradway commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

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 one isOffloaded guard.

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 on metadata.offloaded, mirroring Message.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 under textBlock as metadata rather than a new block type, which would break consumers that switch on type.

Public API Changes

// Before: a placeholder is indistinguishable from tool output
new TextBlock('row-1\nrow-2\n… [truncated ~2,692 tokens] [ref: toolu_abc_0]')

// After: same text, plus a typed annotation
new TextBlock('row-1\nrow-2\n… [truncated ~2,692 tokens] [ref: toolu_abc_0]', {
  metadata: { offloaded: { preview: 'row-1\nrow-2\n… [truncated ~2,692 tokens]', ref: 'toolu_abc_0' } },
})

TextBlock gains an optional metadata?: TextBlockMetadata, serialized only when set. TextBlockMetadata and OffloadedBlockData are exported; isOffloaded is @internal. Old SDKs deserialising a new session read the block as plain text.

Behaviour changes inside the experimental context manager:

  • Message-level removals (utilization drop, summarize, emergency cut) leave a retrievable placeholder, and retrieve_context returns the removed messages. Previously those messages were gone.
  • retrieve_context returns tool result items as blocks, so retrieved media reaches the model as media. Text is still cut to the result budget.
  • An offload reference stays resolvable after repairAlternation merges 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.
  • Placeholders saved by sessions before this change are plain text and are treated as content from here on. This drops the TS regex guard from fix(context): never stash or serve offload previews as originals (py + ts) #5626; the Python side of fix(context): never stash or serve offload previews as originals (py + ts) #5626 is unchanged and already on main.

Related Issues

Related: #5620, #5626. Design context: team/designs/0022 (providerData on TextBlock) 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, and type-check. Exercised the stash, strategies, and retrieve_context end to end through ContextManager and Agent with 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.

  • I ran hatch run prepare (TS-only change; strands-py/ is untouched relative to main)

Additional Details

Why not message metadata, and what else moved

Why block-level rather than Message.metadata: a message can hold several placeholders, and repairAlternation moves 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 and tool::/! filter matching; message-trimming.ts holds the message-level removal and alternation repair that drop, summarize, and the emergency cut share; one _removeMessages path replaces three; a PRESET_STRATEGIES table replaces the preset branch ladder. Message.withContent/withMetadata and ToolResultBlock.withContent replace hand-rolled copies.

Known limitations, deliberately left: Stash.clear() is unused in both SDKs and kept for parity. Agentic-mode removals leave no marker. storeRemoved duplicates media bytes already stashed per block.

Branch note: the branch forks from main before #5626 merged, so the diff currently shows #5626's Python commits. Those files are byte-identical to main and drop out when main is 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.offloaded for parity; docs PR.

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

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.
@github-actions github-actions Bot added the area-context Session or context related label Oct 29, 2026
@lizradway lizradway changed the title Lizradway/offloaded block offloading blcok type Oct 29, 2026
…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 lizradway changed the title offloading blcok type feat(context-ts): record offload placeholders as typed TextBlock metadata Oct 29, 2026
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
@lizradway

Copy link
Copy Markdown
Member Author

Superseded by #5325, which now includes the typed offload placeholders from this PR.

@lizradway lizradway closed this Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-context Session or context related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant