Skip to content
Draft
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
409f701
feat(context)!: redesign offload conditions in typescript
lizradway Oct 21, 2026
7db98fc
test(context): fold offload condition tests into existing strategy tests
lizradway Oct 21, 2026
fddda55
refactor(context): return a plain object from the offload builders
lizradway Oct 21, 2026
c92baa1
fix(context): clarify offload builder error and percent threshold guard
lizradway Oct 21, 2026
9ea9fa1
fix(context): skip a failed summarize run and continue with the rest
lizradway Oct 22, 2026
1f9e95e
fix(context): leave the newest message alone in gated block offloads
lizradway Oct 22, 2026
f0833f4
fix(context): stop message truncation from re-eliding its own marker
lizradway Oct 22, 2026
63e4f8a
refactor(context): rename blockThreshold back to threshold
lizradway Oct 23, 2026
4ed6fcb
feat(context): reject a utilization token count larger than the conte…
lizradway Oct 23, 2026
a58a30d
Merge remote-tracking branch 'origin/main' into lizradway/offload-con…
lizradway Oct 23, 2026
cee79cc
docs(context): say utilization throws above the context window and cl…
lizradway Oct 26, 2026
7790c77
feat(context)!: act on whole messages for target "*" and add a media …
lizradway Oct 26, 2026
14eb3a9
feat(context): shorten each large message in place for truncate("*") …
lizradway Oct 26, 2026
1cb19b9
fix(context): act on blocks of any size when no threshold is set
lizradway Oct 26, 2026
2f794e5
feat(context): say a dropped media block was viewed before it was rem…
lizradway Oct 26, 2026
844d157
fix(context): leave the task prompt to gated block strategies and rej…
lizradway Oct 26, 2026
36adcd0
fix(context): require a context window for a token-count utilization
lizradway Oct 26, 2026
6670fd5
fix(context): warn instead of throwing when a token-count utilization…
lizradway Oct 26, 2026
0617f1a
fix(context): reject condition names that only match Object.prototype…
lizradway Oct 26, 2026
41efdea
fix(context): reject a token-count utilization below 1,000
lizradway Oct 26, 2026
3231184
docs(context): note that truncation detection matches on the text prefix
lizradway Oct 26, 2026
15fd467
fix(context): reject eager media strategies and tighten offload checks
lizradway Oct 26, 2026
8d0694f
fix(context): treat unmeasured media as over the threshold and guard …
lizradway Oct 26, 2026
901c1c5
fix(context): subtract excluded tools from included ones in a mixed t…
lizradway Oct 26, 2026
8b9108b
feat(context)!: accept only "N%" strings and whole numbers as offload…
lizradway Oct 26, 2026
181260c
refactor(context): infer the condition rules shape instead of declari…
lizradway Oct 27, 2026
7aeb480
fix(context-py): never stash or serve offload previews as originals
lizradway Oct 28, 2026
a264eab
fix(context-ts): never stash or serve offload previews as originals
lizradway Oct 28, 2026
17c5c23
feat(context-ts): add OffloadedBlock, a typed placeholder for offload…
lizradway Oct 28, 2026
ec8ab18
refactor(context-ts): treat OffloadedBlock as a text turn across the …
lizradway Oct 28, 2026
b90933f
feat(context-ts): make removed messages retrievable from their marker
lizradway Oct 28, 2026
ec0d5eb
feat(context-ts): send OffloadedBlock as text wherever a provider or …
lizradway Oct 28, 2026
856ef22
refactor(context-ts): every offload placeholder is an OffloadedBlock
lizradway Oct 28, 2026
cb04c8e
refactor(context-ts): read stash entries through the typed deserializers
lizradway Oct 28, 2026
92be138
refactor(context-ts): one message-removal path, one text-turn predicate
lizradway Oct 28, 2026
6e1c537
refactor(context-ts): drop the text-marker placeholder guard
lizradway Oct 28, 2026
33789ae
fix(context-ts): keep an offload reference resolvable after history e…
lizradway Oct 28, 2026
7794a26
refactor(context-ts): one way to copy a message or tool result with o…
lizradway Oct 28, 2026
69df69a
refactor(context-ts): stash retrieve_context results like any other t…
lizradway Oct 28, 2026
c9951aa
test(context-ts): drop tests the typed placeholder made redundant
lizradway Oct 28, 2026
09d43d8
refactor(context-ts): flatten the retrieval tool callback
lizradway Oct 28, 2026
494c661
refactor(context-ts): split the offload base into target matching, hi…
lizradway Oct 29, 2026
64dda15
refactor(context-ts): one table for the context manager presets
lizradway Oct 29, 2026
6a73346
fix(context-ts): address the review of the OffloadedBlock branch
lizradway Oct 29, 2026
7a1fdc5
refactor(context-ts): make OffloadedBlock a TextBlock
lizradway Oct 29, 2026
df6711f
refactor(context-ts): record an offload placeholder as an optional fi…
lizradway Oct 29, 2026
8d29011
refactor(context-ts): name the message-level removal module message-t…
lizradway Oct 29, 2026
7c2e46b
fix(context-ts): never store a placeholder under its original's key, …
lizradway Oct 29, 2026
02737e9
refactor(context-ts): nest the offload placeholder under TextBlock.me…
lizradway Oct 29, 2026
27f7227
feat(context)!: rename offload conditions and make .when() optional
lizradway Oct 29, 2026
b48a5bd
refactor(context-ts): fold typed offload placeholders into the offloa…
lizradway Oct 29, 2026
6b07652
chore: merge main into offload conditions (typescript)
lizradway Oct 29, 2026
3cb0d77
refactor(context-ts): move offload placeholder wording into one modul…
lizradway Oct 29, 2026
0bf25bf
refactor(context-ts): keep the Percent type out of the public exports
lizradway Oct 29, 2026
5bd28c3
refactor(context-ts): collapse the drop and summarize block paths and…
lizradway Oct 29, 2026
d4f13c6
test(context-ts): remove offload tests that other cases already cover
lizradway Oct 29, 2026
3bcb01a
refactor(context-ts): replace RemovalPreview with a private async pla…
lizradway Oct 29, 2026
fbf703c
refactor(context-ts): name the truncated tool result type and call it…
lizradway Oct 29, 2026
4bf5407
refactor(context-ts): store the offload target and eager flag instead…
lizradway Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions strands-ts/src/__fixtures__/context-manager-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Shared helpers for context-manager tests: messages, a character-based token count, and a
* {@link ContextState} over a mock agent.
*/

import { isOffloaded, Message, TextBlock, ToolResultBlock } from '../types/messages.js'
import type { OffloadedBlockData, OffloadMethod } from '../types/messages.js'
import type { Agent } from '../agent/agent.js'
import type { ContextState } from '../context-manager/types.js'
import type { Stash } from '../context-manager/stash.js'
import { createMockAgent } from './agent-helpers.js'

/** A user message holding one successful tool result with the given text. */
export function makeToolResultMessage(text: string, toolUseId = 'tool-123'): Message {
return new Message({
role: 'user',
content: [new ToolResultBlock({ toolUseId, status: 'success', content: [new TextBlock(text)] })],
})
}

/** The offload method that left `block` as a placeholder, or undefined when it is not one. */
export function offloadedBy(block: unknown): OffloadMethod | undefined {
return isOffloaded(block) ? block.metadata.offloaded.method : undefined
}

/** The metadata of every placeholder in `messages`, including those inside tool results, in history order. */
export function placeholdersIn(messages: Message[]): OffloadedBlockData[] {
return messages
.flatMap((message) => message.content)
.flatMap((block): unknown[] => (block instanceof ToolResultBlock ? block.content : [block]))
.filter(isOffloaded)
.map((block) => block.metadata.offloaded)
}

/** Counts text at four characters per token and everything else at two characters per token of its JSON. */
export function heuristicCountTokens(messages: Message[]): number {
let total = 0
for (const message of messages) {
for (const block of message.content) {
if (block instanceof TextBlock) {
total += Math.ceil(block.text.length / 4)
} else if (block instanceof ToolResultBlock) {
for (const content of block.content) {
if (content instanceof TextBlock) total += Math.ceil(content.text.length / 4)
else total += Math.ceil(JSON.stringify(content).length / 2)
}
} else {
total += Math.ceil(JSON.stringify(block).length / 2)
}
}
}
return total
}

export interface MakeContextOptions {
/** Stash the strategies may write to. */
stash?: Stash
/** Context window utilization reported to the strategies. Defaults to 0.5. */
utilization?: number
/** Whether the strategies run on an overflow. */
overflow?: boolean
/**
* Extra members of the agent's model, merged over {@link heuristicCountTokens}. `null` leaves the
* agent without a model.
*/
model?: object | null
}

/** A {@link ContextState} over a mock agent whose model counts tokens with {@link heuristicCountTokens}. */
export function makeContext(messages: Message[], options: MakeContextOptions = {}): ContextState {
const { stash, utilization = 0.5, overflow, model = {} } = options
const extra: Partial<Agent> =
model === null
? {}
: ({ model: { countTokens: async (msgs: Message[]) => heuristicCountTokens(msgs), ...model } } as Partial<Agent>)
const agent = createMockAgent({ messages, extra })
return {
messages,
agent,
utilization,
...(stash !== undefined && { stash }),
...(overflow && { overflow: true }),
}
}
10 changes: 10 additions & 0 deletions strands-ts/src/a2a/__tests__/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ describe('adapters', () => {
])
})

it('converts an offloaded block to the text the model receives', () => {
const blocks: ContentBlock[] = [
new TextBlock('[Dropped] [ref: tu-1]', {
metadata: { offloaded: { method: 'drop', ref: 'tu-1', preview: '[Dropped]' } },
}),
]

expect(contentBlocksToParts(blocks)).toStrictEqual([{ kind: 'text', text: '[Dropped] [ref: tu-1]' }])
})

it.each([
{
desc: 'ImageBlock with bytes',
Expand Down
45 changes: 44 additions & 1 deletion strands-ts/src/agent/__tests__/agent.context-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { Agent } from '../agent.js'
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js'
import { SlidingWindowConversationManager } from '../../conversation-manager/sliding-window-conversation-manager.js'
import { NullConversationManager } from '../../conversation-manager/null-conversation-manager.js'
import { ContextManager } from '../../context-manager/context-manager.js'
import { Offload } from '../../context-manager/strategies/offload/index.js'
import { createRetrievalTool } from '../../context-manager/retrieval-tool.js'
import { offloadPlaceholder } from '../../context-manager/placeholder.js'
import { tool } from '../../tools/tool-factory.js'
import { TextBlock, ToolResultBlock } from '../../types/messages.js'
import type { ConversationManager } from '../../conversation-manager/conversation-manager.js'

function internals(agent: Agent): any {
Expand Down Expand Up @@ -139,4 +145,41 @@ describe('Agent contextManager', () => {
expect(plugins.get('strands:context-manager')).toBe(contextManager)
})
})

describe('offloaded tool results', () => {
it('stores an sends the model its text, and resolves the toolUseId to the original', async () => {
const rows = tool({
name: 'rows',
description: 'lists rows',
inputSchema: z.object({}),
callback: () => Array.from({ length: 5000 }, (_, index) => `row-${index + 1}`).join('\n'),
})
const model = new MockMessageModel()
.addTurn({ type: 'toolUseBlock', name: 'rows', toolUseId: 'tu-1', input: {} })
.addTurn({ type: 'textBlock', text: 'done' })
const sent = vi.spyOn(model, 'streamAggregated')
const agent = new Agent({
model,
tools: [rows],
printer: false,
contextManager: new ContextManager({
strategies: [Offload.truncate('toolResults', { previewTokens: 100 }).when({ blockThreshold: 200 })],
}),
})

await agent.invoke('list the rows')

const stored = (agent.messages[2]!.content[0] as ToolResultBlock).content[0] as TextBlock
expect(stored).toEqual(offloadPlaceholder('truncate', stored.metadata!.offloaded!.preview, 'tu-1'))
const received = (sent.mock.calls[1]![0][2]!.content[0] as ToolResultBlock).content[0]
expect(received).toBeInstanceOf(TextBlock)
expect((received as TextBlock).text).toBe(stored.text)

const retrieve = createRetrievalTool(agent.contextManager!.stash!) as unknown as {
invoke(input: unknown): Promise<unknown>
}
expect(await retrieve.invoke({ reference: 'tu-1', pattern: 'row-4321' })).toContain('row-4321')
expect(await retrieve.invoke({ reference: 'tu-1_0', pattern: 'row-4321' })).toContain('row-4321')
})
})
})
8 changes: 7 additions & 1 deletion strands-ts/src/agent/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1923,7 +1923,13 @@ describe('Agent', () => {
new ToolResultBlock({
toolUseId: 'tool-1',
status: 'success',
content: [new TextBlock(expect.stringContaining('[Truncated:'))],
content: [
new TextBlock(expect.any(String), {
metadata: {
offloaded: { method: 'truncate', preview: expect.any(String), ref: 'tool-1' },
},
}),
],
}),
])
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,17 @@ describe('ContextManager', () => {
new ToolResultBlock({
toolUseId: 'tu-1',
status: 'success',
content: [new TextBlock('[Truncated: 1 block, ~7,500 tokens] preview\n\n[Stashed] [ref: tu-1_0]')],
content: [
new TextBlock('[Truncated: 1 block, ~7,500 tokens] preview [ref: tu-1]', {
metadata: {
offloaded: {
method: 'truncate',
ref: 'tu-1',
preview: '[Truncated: 1 block, ~7,500 tokens] preview',
},
},
}),
],
}),
],
})
Expand Down
58 changes: 58 additions & 0 deletions strands-ts/src/context-manager/__tests__/placeholder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
formatDropped,
formatDroppedMessages,
formatElided,
formatOffloaded,
formatSummarized,
formatTruncated,
offloadPlaceholder,
} from '../placeholder.js'
import { TextBlock } from '../../types/messages.js'

describe('offloadPlaceholder', () => {
it('appends the stash ref and records what the placeholder stands for', () => {
expect(offloadPlaceholder('drop', '[Dropped: 2 messages]', 'removed-abc', 2)).toStrictEqual(
new TextBlock('[Dropped: 2 messages] [ref: removed-abc]', {
metadata: {
offloaded: { method: 'drop', preview: '[Dropped: 2 messages]', ref: 'removed-abc', removedMessages: 2 },
},
})
)
})

it('leaves out the ref when the original was not stashed', () => {
expect(offloadPlaceholder('drop', '[Dropped]', undefined)).toStrictEqual(
new TextBlock('[Dropped]', { metadata: { offloaded: { method: 'drop', preview: '[Dropped]' } } })
)
})
})

describe('markers', () => {
it.each([
['a dropped block', formatDropped(), '[Dropped]'],
['dropped media', formatDropped('image'), '[Dropped: image, removed from context after it was viewed]'],
['one dropped message', formatDroppedMessages(1), '[Dropped: 1 message]'],
['dropped messages', formatDroppedMessages(3), '[Dropped: 3 messages]'],
['an offloaded block', formatOffloaded(2525), '[Offloaded: ~2,525 tokens]'],
['an unmeasured block', formatOffloaded(0), '[Offloaded]'],
[
'offloaded media',
formatOffloaded(2525, 'document'),
'[Offloaded: document, ~2,525 tokens, removed from context after it was viewed]',
],
['unmeasured media', formatOffloaded(0, 'image'), '[Offloaded: image, removed from context after it was viewed]'],
['a summary', formatSummarized('tool result', 2525, 'short'), '[Summarized: tool result, ~2,525 tokens]\n\nshort'],
[
'an unmeasured summary',
formatSummarized('media block', 0, 'a red square'),
'[Summarized: media block]\n\na red square',
],
['a truncated block', formatTruncated(1, 7500, 'head'), '[Truncated: 1 block, ~7,500 tokens]\n\nhead'],
['truncated blocks', formatTruncated(2, 7500, 'head'), '[Truncated: 2 blocks, ~7,500 tokens]\n\nhead'],
['one elided message', formatElided(1), '[... 1 message elided ...]'],
['elided messages', formatElided(7), '[... 7 messages elided ...]'],
])('formats %s', (_case, text, expected) => {
expect(text).toBe(expected)
})
})
19 changes: 16 additions & 3 deletions strands-ts/src/context-manager/__tests__/presets.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest'
import { resolvePreset, resolveStrategies, STRATEGY_PRESET_NAMES } from '../presets.js'
import { ContextManager } from '../context-manager.js'
import { Offload } from '../strategies/offload/index.js'
import type { ContextStrategy } from '../types.js'

describe('presets', () => {
describe('resolvePreset', () => {
Expand All @@ -25,10 +27,10 @@ describe('presets', () => {
expect(strategies[0]!.name).toBe('offload:truncate')
})

it('overflowProtection resolves to a truncate strategy', () => {
it('overflowProtection resolves to a drop strategy', () => {
const strategies = resolvePreset('overflowProtection')
expect(strategies).toHaveLength(1)
expect(strategies[0]!.name).toBe('offload:truncate')
expect(strategies[0]!.name).toBe('offload:drop')
})

it('staleToolCleanup resolves to a drop strategy', () => {
Expand Down Expand Up @@ -57,7 +59,7 @@ describe('presets', () => {
expect(result).toHaveLength(3)
expect(result[0]!.name).toBe('offload:truncate')
expect(result[1]!.name).toBe('custom')
expect(result[2]!.name).toBe('offload:truncate')
expect(result[2]!.name).toBe('offload:drop')
})

it('preserves order of preset expansion', () => {
Expand All @@ -72,6 +74,17 @@ describe('presets', () => {
'Unknown strategy preset: "proactiveSummarisation"'
)
})

it.each([null, undefined])('rejects a %s entry', (entry) => {
expect(() => resolveStrategies([entry as unknown as ContextStrategy])).toThrow(
`Strategy entries must be a strategy or a preset name, got ${String(entry)}`
)
})

it('accepts an Offload strategy without .when()', () => {
const strategy = Offload.drop('toolResults')
expect(resolveStrategies([strategy])).toStrictEqual([strategy])
})
})

describe('ContextManager.from', () => {
Expand Down
Loading
Loading