diff --git a/strands-ts/src/agent/__tests__/agent.context-manager.test.ts b/strands-ts/src/agent/__tests__/agent.context-manager.test.ts index 109fb6590e..050049692e 100644 --- a/strands-ts/src/agent/__tests__/agent.context-manager.test.ts +++ b/strands-ts/src/agent/__tests__/agent.context-manager.test.ts @@ -2,12 +2,8 @@ import { describe, expect, it } from 'vitest' import { Agent } from '../agent.js' import { MockMessageModel } from '../../__fixtures__/mock-message-model.js' import { SlidingWindowConversationManager } from '../../conversation-manager/sliding-window-conversation-manager.js' -import { SummarizingConversationManager } from '../../conversation-manager/summarizing-conversation-manager.js' import { NullConversationManager } from '../../conversation-manager/null-conversation-manager.js' -import { ContextOffloader } from '../../vended-plugins/context-offloader/plugin.js' -import { InMemoryStorage as LegacyInMemoryStorage } from '../../vended-plugins/context-offloader/storage.js' import { ContextManager } from '../../context-manager/context-manager.js' -import { NAMESPACED } from '../../storage/storage.js' import type { ConversationManager } from '../../conversation-manager/conversation-manager.js' function internals(agent: Agent): any { @@ -18,10 +14,6 @@ function getConversationManager(agent: Agent): ConversationManager { return internals(agent)._conversationManager } -function getPending(agent: Agent): any[] { - return internals(agent)._pluginRegistry._pending -} - describe('Agent contextManager', () => { describe('when undefined (default)', () => { it('uses SlidingWindowConversationManager', () => { @@ -30,76 +22,53 @@ describe('Agent contextManager', () => { expect(getConversationManager(agent)).toBeInstanceOf(SlidingWindowConversationManager) }) - it('does not add ContextOffloader plugin', () => { + it('does not set contextManager', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const agent = new Agent({ model }) - const pending = getPending(agent) - expect(pending.find((p: any) => p.name === 'strands:context-offloader')).toBeUndefined() + expect(agent.contextManager).toBeUndefined() }) }) describe('when "auto"', () => { - it('uses SummarizingConversationManager', () => { - const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - const agent = new Agent({ model, contextManager: 'auto' }) - expect(getConversationManager(agent)).toBeInstanceOf(SummarizingConversationManager) - }) - - it('sets summaryRatio to 0.3', () => { + it('uses NullConversationManager', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const agent = new Agent({ model, contextManager: 'auto' }) - const conversationManager = getConversationManager(agent) as any - expect(conversationManager._summaryRatio).toBe(0.3) + expect(getConversationManager(agent)).toBeInstanceOf(NullConversationManager) }) - it('enables proactive compression at 0.85', () => { + it('creates a ContextManager instance', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const agent = new Agent({ model, contextManager: 'auto' }) - const conversationManager = getConversationManager(agent) as any - expect(conversationManager._compressionThreshold).toBe(0.85) + expect(agent.contextManager).toBeInstanceOf(ContextManager) }) - it('adds ContextOffloader plugin with benchmark defaults', async () => { + it('registers ContextManager as a plugin', async () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const agent = new Agent({ model, contextManager: 'auto' }) await agent.invoke('hi') const plugins = internals(agent)._pluginRegistry._plugins - const offloader = plugins.get('strands:context-offloader') as any - expect(offloader).toBeDefined() - expect(offloader._maxResultTokens).toBe(1500) - expect(offloader._previewTokens).toBe(750) - expect(NAMESPACED in offloader._storage).toBe(true) + expect(plugins.get('strands:context-manager')).toBe(agent.contextManager) }) - }) - describe('coexistence with conversationManager', () => { - it('respects user-provided conversationManager', () => { + it('ignores user-provided conversationManager', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const userCm = new SlidingWindowConversationManager({ windowSize: 20 }) const agent = new Agent({ model, contextManager: 'auto', conversationManager: userCm }) - expect(getConversationManager(agent)).toBe(userCm) + expect(getConversationManager(agent)).toBeInstanceOf(NullConversationManager) }) + }) - it('still adds ContextOffloader when user provides conversationManager', () => { + describe('when "agentic"', () => { + it('uses NullConversationManager', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - const userCm = new SlidingWindowConversationManager({ windowSize: 20 }) - const agent = new Agent({ model, contextManager: 'auto', conversationManager: userCm }) - const pending = getPending(agent) - expect(pending.find((p: any) => p.name === 'strands:context-offloader')).toBeDefined() + const agent = new Agent({ model, contextManager: 'agentic' }) + expect(getConversationManager(agent)).toBeInstanceOf(NullConversationManager) }) - it('does not add duplicate ContextOffloader if user provides one', () => { + it('creates a ContextManager instance', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - const userOffloader = new ContextOffloader({ - storage: new LegacyInMemoryStorage(), - maxResultTokens: 3000, - previewTokens: 1000, - }) - const agent = new Agent({ model, contextManager: 'auto', plugins: [userOffloader] }) - const pending = getPending(agent) - const offloaders = pending.filter((p: any) => p.name === 'strands:context-offloader') - expect(offloaders).toHaveLength(1) - expect((offloaders[0] as any)._maxResultTokens).toBe(3000) + const agent = new Agent({ model, contextManager: 'agentic' }) + expect(agent.contextManager).toBeInstanceOf(ContextManager) }) }) @@ -128,37 +97,27 @@ describe('Agent contextManager', () => { const agent = new Agent({ model, contextManager: false, conversationManager: userCm }) expect(getConversationManager(agent)).toBe(userCm) }) + + it('does not set contextManager', () => { + const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) + const agent = new Agent({ model, contextManager: false }) + expect(agent.contextManager).toBeUndefined() + }) }) - describe('when ContextManager instance', () => { + describe('when ContextManagerConfig object', () => { it('uses NullConversationManager', () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - const cm = new ContextManager({ strategies: [{ name: 'noop', apply: async () => false }] }) - const agent = new Agent({ model, contextManager: cm }) + const agent = new Agent({ model, contextManager: { strategies: [{ name: 'noop', apply: async () => false }] } }) expect(getConversationManager(agent)).toBeInstanceOf(NullConversationManager) }) it('registers ContextManager as a plugin', async () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - const cm = new ContextManager({ strategies: [{ name: 'noop', apply: async () => false }] }) - const agent = new Agent({ model, contextManager: cm }) + const agent = new Agent({ model, contextManager: { strategies: [{ name: 'noop', apply: async () => false }] } }) await agent.invoke('hi') const plugins = internals(agent)._pluginRegistry._plugins - expect(plugins.get('strands:context-manager')).toBe(cm) - }) - - it('throws if ContextManager is passed in both param and plugins', async () => { - const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - const cm = new ContextManager({ strategies: [{ name: 'noop', apply: async () => false }] }) - const agent = new Agent({ model, contextManager: cm, plugins: [cm] }) - await expect(agent.invoke('hi')).rejects.toThrow('plugin already registered') - }) - }) - - describe('unsupported value', () => { - it('throws for invalid contextManager value', () => { - const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - expect(() => new Agent({ model, contextManager: 'manual' as any })).toThrow('Unsupported contextManager value') + expect(plugins.get('strands:context-manager')).toBeInstanceOf(ContextManager) }) }) }) diff --git a/strands-ts/src/agent/__tests__/agent.storage.test.ts b/strands-ts/src/agent/__tests__/agent.storage.test.ts index 491bb3f5b9..1b365bf84c 100644 --- a/strands-ts/src/agent/__tests__/agent.storage.test.ts +++ b/strands-ts/src/agent/__tests__/agent.storage.test.ts @@ -2,61 +2,25 @@ import { describe, expect, it } from 'vitest' import { Agent } from '../agent.js' import { MockMessageModel } from '../../__fixtures__/mock-message-model.js' import { InMemoryStorage } from '../../storage/in-memory-storage.js' -import { NAMESPACED } from '../../storage/storage.js' -import { ContextOffloader } from '../../vended-plugins/context-offloader/plugin.js' import { SessionManager } from '../../session/session-manager.js' -function internals(agent: Agent): any { - return agent as any -} - describe('Agent storage', () => { - describe('agent-level storage flows to ContextOffloader', () => { - it('auto-created offloader uses agent storage when provided', async () => { + describe('agent-level storage flows to ContextManager stash', () => { + it('auto-created ContextManager uses agent storage when provided', async () => { const storage = new InMemoryStorage() const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const agent = new Agent({ model, contextManager: 'auto', storage }) await agent.invoke('hi') - const plugins = internals(agent)._pluginRegistry._plugins - const offloader = plugins.get('strands:context-offloader') as any - expect(offloader._storage).toBeDefined() - expect(NAMESPACED in offloader._storage).toBe(true) - - await offloader._storage.write('test-key', new TextEncoder().encode('test-value')) - const stored = await storage.read('offloader/test-key') - expect(stored).not.toBeNull() - expect(new TextDecoder().decode(stored!)).toBe('test-value') + expect(agent.contextManager).toBeDefined() + expect(agent.contextManager!.stash).toBeDefined() }) - it('auto-created offloader falls back to InMemoryStorage when no agent storage', async () => { + it('auto-created ContextManager uses InMemoryStorage when no agent storage', async () => { const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) const agent = new Agent({ model, contextManager: 'auto' }) await agent.invoke('hi') - const plugins = internals(agent)._pluginRegistry._plugins - const offloader = plugins.get('strands:context-offloader') as any - expect(offloader._storage).toBeDefined() - expect(NAMESPACED in offloader._storage).toBe(true) - }) - - it('explicit offloader storage overrides agent storage', async () => { - const agentStorage = new InMemoryStorage() - const offloaderStorage = new InMemoryStorage() - const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) - const agent = new Agent({ - model, - storage: agentStorage, - plugins: [new ContextOffloader({ storage: offloaderStorage, maxResultTokens: 1500, previewTokens: 750 })], - contextManager: 'auto', - }) - await agent.invoke('hi') - const plugins = internals(agent)._pluginRegistry._plugins - const offloader = plugins.get('strands:context-offloader') as any - - await offloader._storage.write('test-key', new TextEncoder().encode('test-value')) - const inOffloader = await offloaderStorage.read('offloader/test-key') - expect(inOffloader).not.toBeNull() - const inAgent = await agentStorage.read('offloader/test-key') - expect(inAgent).toBeNull() + expect(agent.contextManager).toBeDefined() + expect(agent.contextManager!.stash).toBeDefined() }) }) diff --git a/strands-ts/src/agent/agent.ts b/strands-ts/src/agent/agent.ts index b489dad2eb..3954c7478b 100644 --- a/strands-ts/src/agent/agent.ts +++ b/strands-ts/src/agent/agent.ts @@ -44,10 +44,8 @@ import { InterventionRegistry } from '../interventions/registry.js' import type { LifecycleObserver } from '../types/lifecycle-observer.js' import { PluginRegistry } from '../plugins/registry.js' import { SlidingWindowConversationManager } from '../conversation-manager/sliding-window-conversation-manager.js' -import { SummarizingConversationManager } from '../conversation-manager/summarizing-conversation-manager.js' import { NullConversationManager } from '../conversation-manager/null-conversation-manager.js' import { ConversationManager } from '../conversation-manager/conversation-manager.js' -import { ContextOffloader } from '../vended-plugins/context-offloader/plugin.js' import { AgentDelegation } from './agent-delegation.js' import type { Storage } from '../storage/storage.js' import { HookRegistryImplementation } from '../hooks/registry.js' @@ -124,6 +122,7 @@ import { createTokenUsageMiddleware, } from '../context-manager/modes/agentic/agentic-context.js' import { ContextManager } from '../context-manager/context-manager.js' +import type { ContextManagerStrategy } from '../context-manager/context-manager.js' import { BackgroundTasks } from '../background-tasks/background-tasks.js' import type { BackgroundTasksConfig } from '../background-tasks/types.js' @@ -152,33 +151,6 @@ export type ToolList = (Tool | McpClient | Agent | ToolList)[] */ export type ToolExecutorStrategy = 'sequential' | 'concurrent' -/** - * Supported string presets for the `contextManager` parameter. - */ -export const CONTEXT_MANAGER_STRATEGIES = ['auto', 'agentic'] as const -type ContextManagerPreset = (typeof CONTEXT_MANAGER_STRATEGIES)[number] - -/** - * Supported values for the `contextManager` parameter. - * - * - `"auto"`: Managed context with proactive compression + offloading. - * - `"agentic"`: Model-driven context management via injected tools. - * - `ContextManager` instance: Full control over strategy-driven offloading. - * - `false`: Explicitly disable all context management (no compression, no offloading). - */ -export type ContextManagerStrategy = ContextManagerPreset | ContextManager | false - -/** Benchmark-validated token threshold for offloading tool results. */ -const CONTEXT_MANAGER_MAX_RESULT_TOKENS = 1_500 -/** Higher offload threshold for agentic mode — the model manages its own context, so we preserve more inline. */ -const AGENTIC_CONTEXT_MANAGER_MAX_RESULT_TOKENS = 8_000 -/** Benchmark-validated preview token count for offloaded results. */ -const CONTEXT_MANAGER_PREVIEW_TOKENS = 750 -/** Benchmark-validated ratio of messages to summarize on overflow. */ -const CONTEXT_MANAGER_SUMMARY_RATIO = 0.3 -/** Benchmark-validated context window ratio that triggers proactive compression. */ -const CONTEXT_MANAGER_COMPRESSION_THRESHOLD = 0.85 - /** * Configuration object for creating a new Agent. */ @@ -239,18 +211,15 @@ export type AgentConfig = { /** * Context management strategy that controls how messages are compressed and offloaded. * - * - `"auto"`: SummarizingConversationManager with proactive compression + ContextOffloader. - * - `"agentic"`: (Experimental) Lets the model drive context management via injected tools. + * - `"auto"`: Proactive truncation of tool results + summarization at 85% utilization. + * - `"agentic"`: (Experimental) Lets the model drive context management via injected tools, + * with a higher truncation threshold and summarization only on overflow. * This mode may change in future versions. - * - `ContextManager` instance: Strategy-driven offloading with overflow recovery. + * - `ContextManagerConfig` object: Custom strategy pipeline and stash configuration. * - `false`: Explicitly disable context management (no compression, no offloading). * - * When a `ContextManager` instance is provided, any co-provided `conversationManager` is ignored. - * Defaults to undefined (SlidingWindowConversationManager, no offloader). - * - * @remarks The offloader uses in-memory storage by default. When an agent-level - * `storage` is provided, the offloader uses that instead. Alternatively, provide - * an explicit `ContextOffloader` with its own storage via the `plugins` parameter. + * When set (except `false`), any co-provided `conversationManager` is ignored. + * Defaults to undefined (SlidingWindowConversationManager). */ contextManager?: ContextManagerStrategy /** @@ -345,10 +314,10 @@ export type AgentConfig = { * Default storage backend for agent subsystems. * * When provided, subsystems that do not have their own explicit storage - * (e.g., SessionManager, ContextOffloader) resolve from this value. Each - * subsystem auto-namespaces under its own prefix (`session/`, `offloader/`) - * to avoid key collisions. Storage specified directly on a subsystem always - * takes precedence over this agent-level default. + * (e.g., SessionManager, ContextManager) resolve from this value. Each + * subsystem auto-namespaces under its own prefix to avoid key collisions. + * Storage specified directly on a subsystem always takes precedence over + * this agent-level default. */ storage?: Storage } @@ -357,45 +326,23 @@ export type AgentConfig = { * Resolve the contextManager facade into a concrete ConversationManager. * * When contextManager is undefined, falls back to the default SlidingWindowConversationManager. - * When "auto", uses SummarizingConversationManager with proactive compression. - * When "agentic", uses SummarizingConversationManager without proactive compression - * (the agent manages its context via tools; the context manager is only a reactive safety net). - * When a ContextManager instance, uses NullConversationManager — the ContextManager owns - * overflow recovery via apply(). + * When a preset, config object, or false, uses NullConversationManager — + * the ContextManager owns overflow recovery and proactive compression. */ function resolveConversationManager( contextManager: ContextManagerStrategy | undefined, conversationManager: ConversationManager | undefined ): ConversationManager { + if (contextManager === undefined) { + return conversationManager ?? new SlidingWindowConversationManager({ windowSize: 40 }) + } if (contextManager === false) { return conversationManager ?? new NullConversationManager() } - if (contextManager instanceof ContextManager) { - return new NullConversationManager() - } - if (contextManager === 'agentic') { - return ( - conversationManager ?? - new SummarizingConversationManager({ - summaryRatio: CONTEXT_MANAGER_SUMMARY_RATIO, - }) - ) - } - if (contextManager === 'auto') { - return ( - conversationManager ?? - new SummarizingConversationManager({ - summaryRatio: CONTEXT_MANAGER_SUMMARY_RATIO, - proactiveCompression: { compressionThreshold: CONTEXT_MANAGER_COMPRESSION_THRESHOLD }, - }) - ) + if (conversationManager) { + logger.warn('contextManager is set, ignoring co-provided conversationManager') } - if (contextManager !== undefined) { - throw new Error( - `Unsupported contextManager value: "${contextManager}". Supported values: ${CONTEXT_MANAGER_STRATEGIES.map((s) => `"${s}"`).join(', ')}` - ) - } - return conversationManager ?? new SlidingWindowConversationManager({ windowSize: 40 }) + return new NullConversationManager() } /** @@ -566,7 +513,7 @@ export class Agent implements LocalAgent, InvokableAgent { this.name = config?.name ?? DEFAULT_AGENT_NAME this.id = config?.id ?? DEFAULT_AGENT_ID if (config?.description !== undefined) this.description = config.description - this.contextManager = config?.contextManager instanceof ContextManager ? config.contextManager : undefined + this.contextManager = ContextManager.from(config?.contextManager) this.sessionManager = config?.sessionManager this.storage = config?.storage this.memoryManager = @@ -642,13 +589,10 @@ export class Agent implements LocalAgent, InvokableAgent { // - Retry-strategy ordering is not load-bearing for correctness: `DefaultModelRetryStrategy` // guards on `event.retry`, so a user hook that already set it short-circuits // the strategy regardless of registration order. - const hasOffloader = (config?.plugins ?? []).some((p) => p.name === 'strands:context-offloader') // Always register AgentDelegation so delegation semantics work regardless of // when a delegate tool is added (construction, plugin getTools, MCP, runtime). // The plugin is a no-op when no delegation tools fire. const hasAgentDelegation = (config?.plugins ?? []).some((p) => p.name === 'strands:agent-delegation') - - const contextManagerPlugin = config?.contextManager instanceof ContextManager ? config.contextManager : undefined this._backgroundTasks = config?.backgroundTasks ? new BackgroundTasks( config.backgroundTasks === true ? {} : config.backgroundTasks, @@ -681,19 +625,8 @@ export class Agent implements LocalAgent, InvokableAgent { ...(config?.plugins ?? []), ...(this._backgroundTasks ? [this._backgroundTasks] : []), ...(!hasAgentDelegation ? [new AgentDelegation()] : []), - ...((config?.contextManager === 'auto' || config?.contextManager === 'agentic') && !hasOffloader - ? [ - new ContextOffloader({ - maxResultTokens: - config?.contextManager === 'agentic' - ? AGENTIC_CONTEXT_MANAGER_MAX_RESULT_TOKENS - : CONTEXT_MANAGER_MAX_RESULT_TOKENS, - previewTokens: CONTEXT_MANAGER_PREVIEW_TOKENS, - }), - ] - : []), ...(this.memoryManager ? [this.memoryManager] : []), - ...(contextManagerPlugin ? [contextManagerPlugin] : []), + ...(this.contextManager ? [this.contextManager] : []), ...(config?.sessionManager ? [config.sessionManager] : []), new ModelPlugin(this.model), ]) diff --git a/strands-ts/src/context-manager/__tests__/context-manager.test.ts b/strands-ts/src/context-manager/__tests__/context-manager.test.ts index e0c6835ba4..b387ba4a55 100644 --- a/strands-ts/src/context-manager/__tests__/context-manager.test.ts +++ b/strands-ts/src/context-manager/__tests__/context-manager.test.ts @@ -163,7 +163,7 @@ describe('ContextManager', () => { expect(event.retry).toBe(true) }) - it('does not truncate when strategies bring utilization below 1.0', async () => { + it('does not truncate on non-overflow when utilization is below 1.0', async () => { const messages = [ new Message({ role: 'user', content: [new TextBlock('system')] }), new Message({ role: 'assistant', content: [new TextBlock('response 1')] }), @@ -173,7 +173,7 @@ describe('ContextManager', () => { new Message({ role: 'assistant', content: [new TextBlock('response 3')] }), ] - const strategy = { name: 'noop', apply: async () => true } + const strategy = { name: 'test', apply: async () => false } const cm = new ContextManager({ strategies: [strategy] }) const agent = makeMockAgent({ messages, @@ -183,12 +183,46 @@ describe('ContextManager', () => { await cm.initAgent(agent) const originalLength = messages.length - const event = makeOverflowEvent(agent) + const event = new BeforeModelCallEvent({ + agent, + model: agent.model, + invocationState: {}, + projectedInputTokens: 100, + }) await invokeTrackedHook(agent, event) expect(messages.length).toBe(originalLength) }) + it('emergency truncate fires when estimate undercounts on overflow', async () => { + const messages = [ + new Message({ role: 'user', content: [new TextBlock('system')] }), + new Message({ role: 'assistant', content: [new TextBlock('response 1')] }), + new Message({ role: 'user', content: [new TextBlock('msg 2')] }), + new Message({ role: 'assistant', content: [new TextBlock('response 2')] }), + new Message({ role: 'user', content: [new TextBlock('msg 3')] }), + new Message({ role: 'assistant', content: [new TextBlock('response 3')] }), + new Message({ role: 'user', content: [new TextBlock('msg 4')] }), + new Message({ role: 'assistant', content: [new TextBlock('response 4')] }), + ] + + const strategy = { name: 'noop', apply: async () => false } + const cm = new ContextManager({ strategies: [strategy] }) + const agent = makeMockAgent({ + messages, + countTokens: async () => 100, + estimateUtilization: () => 0.5, + }) + await cm.initAgent(agent) + + const originalLength = messages.length + const event = makeOverflowEvent(agent) + await invokeTrackedHook(agent, event) + + expect(messages.length).toBeLessThan(originalLength) + expect(event.retry).toBe(true) + }) + it('caps retries at 3 and stops setting retry', async () => { const strategy = { name: 'noop', apply: async () => false } const cm = new ContextManager({ strategies: [strategy] }) diff --git a/strands-ts/src/context-manager/__tests__/presets.test.ts b/strands-ts/src/context-manager/__tests__/presets.test.ts new file mode 100644 index 0000000000..6a67635f80 --- /dev/null +++ b/strands-ts/src/context-manager/__tests__/presets.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest' +import { resolvePreset, resolveStrategies, STRATEGY_PRESET_NAMES } from '../presets.js' +import { ContextManager } from '../context-manager.js' + +describe('presets', () => { + describe('resolvePreset', () => { + it.each(STRATEGY_PRESET_NAMES)('resolves %s to a non-empty strategy array', (name) => { + const strategies = resolvePreset(name) + expect(strategies.length).toBeGreaterThan(0) + for (const strategy of strategies) { + expect(strategy).toHaveProperty('name') + expect(strategy).toHaveProperty('apply') + } + }) + + it('proactiveSummarization resolves to a summarize strategy', () => { + const strategies = resolvePreset('proactiveSummarization') + expect(strategies).toHaveLength(1) + expect(strategies[0]!.name).toBe('offload:summarize') + }) + + it('largeToolOffloading resolves to a truncate strategy', () => { + const strategies = resolvePreset('largeToolOffloading') + expect(strategies).toHaveLength(1) + expect(strategies[0]!.name).toBe('offload:truncate') + }) + + it('overflowProtection resolves to a truncate strategy', () => { + const strategies = resolvePreset('overflowProtection') + expect(strategies).toHaveLength(1) + expect(strategies[0]!.name).toBe('offload:truncate') + }) + + it('staleToolCleanup resolves to a drop strategy', () => { + const strategies = resolvePreset('staleToolCleanup') + expect(strategies).toHaveLength(1) + expect(strategies[0]!.name).toBe('offload:drop') + }) + }) + + describe('resolveStrategies', () => { + it('passes raw strategies through unchanged', () => { + const raw = { name: 'custom', apply: async () => false } + const result = resolveStrategies([raw]) + expect(result).toEqual([raw]) + }) + + it('resolves preset strings to strategy arrays', () => { + const result = resolveStrategies(['largeToolOffloading']) + expect(result).toHaveLength(1) + expect(result[0]!.name).toBe('offload:truncate') + }) + + it('handles mixed arrays of presets and raw strategies', () => { + const raw = { name: 'custom', apply: async () => false } + const result = resolveStrategies(['largeToolOffloading', raw, 'overflowProtection']) + expect(result).toHaveLength(3) + expect(result[0]!.name).toBe('offload:truncate') + expect(result[1]!.name).toBe('custom') + expect(result[2]!.name).toBe('offload:truncate') + }) + + it('preserves order of preset expansion', () => { + const result = resolveStrategies(['staleToolCleanup', 'proactiveSummarization']) + expect(result).toHaveLength(2) + expect(result[0]!.name).toBe('offload:drop') + expect(result[1]!.name).toBe('offload:summarize') + }) + + it('throws for unknown preset string', () => { + expect(() => resolveStrategies(['proactiveSummarisation' as any])).toThrow( + 'Unknown strategy preset: "proactiveSummarisation"' + ) + }) + }) + + describe('ContextManager.from', () => { + it('resolves preset strings in a config strategies array', () => { + const cm = ContextManager.from({ strategies: ['largeToolOffloading', 'proactiveSummarization'] }) + expect(cm).toBeInstanceOf(ContextManager) + }) + + it('resolves a mix of preset strings and raw strategies', () => { + const cm = ContextManager.from({ + strategies: ['largeToolOffloading', { name: 'custom', apply: async () => false }], + }) + expect(cm).toBeInstanceOf(ContextManager) + }) + + it('resolves "auto" preset', () => { + expect(ContextManager.from('auto')).toBeInstanceOf(ContextManager) + }) + + it('resolves "agentic" preset', () => { + expect(ContextManager.from('agentic')).toBeInstanceOf(ContextManager) + }) + + it('returns undefined for false', () => { + expect(ContextManager.from(false)).toBeUndefined() + }) + + it('returns undefined for undefined', () => { + expect(ContextManager.from(undefined)).toBeUndefined() + }) + + it('throws for unknown preset string', () => { + expect(() => ContextManager.from('atuo' as any)).toThrow('Unknown contextManager preset') + }) + }) +}) diff --git a/strands-ts/src/context-manager/context-manager.ts b/strands-ts/src/context-manager/context-manager.ts index 63b2d8fc1c..0f9f8f7391 100644 --- a/strands-ts/src/context-manager/context-manager.ts +++ b/strands-ts/src/context-manager/context-manager.ts @@ -15,9 +15,31 @@ import type { Storage } from '../storage/storage.js' import { logger } from '../logging/logger.js' import type { ContextManagerConfig, ContextStrategy, ContextState } from './types.js' import { EmergencyTruncateStrategy, Offload } from './strategies/offload/index.js' +import { resolveStrategies } from './presets.js' import { Stash } from './stash.js' import { createRetrievalTool, trackRetrievalToolUseIds } from './retrieval-tool.js' +const AUTO_TRUNCATE_THRESHOLD = 1_500 +const AGENTIC_TRUNCATE_THRESHOLD = 8_000 +const TRUNCATE_PREVIEW_TOKENS = 750 +const AUTO_SUMMARIZE_UTILIZATION = 0.85 + +/** @internal */ +export const CONTEXT_MANAGER_PRESETS = ['auto', 'agentic'] as const + +/** @internal */ +export type ContextManagerPreset = (typeof CONTEXT_MANAGER_PRESETS)[number] + +/** + * Supported values for the `contextManager` parameter. + * + * - `"auto"`: Managed context with proactive compression + offloading. + * - `"agentic"`: Model-driven context management via injected tools. + * - {@link ContextManagerConfig}: Custom strategy pipeline and stash configuration. + * - `false`: Explicitly disable all context management (no compression, no offloading). + */ +export type ContextManagerStrategy = ContextManagerPreset | ContextManagerConfig | false + /** * Manages context reduction for an agent's conversation. * @@ -25,9 +47,10 @@ import { createRetrievalTool, trackRetrievalToolUseIds } from './retrieval-tool. * The emergency truncation is always appended as the final strategy — it recomputes * utilization and only fires if the window is still overflowing after user strategies. * - * The ContextManager is a first-class agent component — pass it via the - * `contextManager` parameter on the Agent constructor. When present, it owns - * overflow recovery — no separate ConversationManager is needed. + * Configured through the Agent's `contextManager` parameter — pass a preset + * string (`'auto'`, `'agentic'`) or a `ContextManagerConfig`; the Agent + * constructs and registers the manager. When present, it owns overflow + * recovery and proactive compression — no separate ConversationManager is needed. * * @experimental */ @@ -43,19 +66,50 @@ export class ContextManager implements Plugin { private _retrievalTool: Tool | undefined constructor(config?: ContextManagerConfig) { - this._strategies = [ - ...(config?.strategies ?? [ - Offload.truncate('toolResults').when({ threshold: 2500 }), - Offload.summarize('*').when({ threshold: 1000, utilization: 0.85 }), - ]), - new EmergencyTruncateStrategy(), - ] + const userStrategies = config?.strategies + ? resolveStrategies(config.strategies) + : [ + Offload.truncate('toolResults', { previewTokens: TRUNCATE_PREVIEW_TOKENS }).when({ + threshold: AUTO_TRUNCATE_THRESHOLD, + }), + Offload.summarize('*').when({ utilization: AUTO_SUMMARIZE_UTILIZATION, preserveRecent: 4 }), + ] + this._strategies = [...userStrategies, new EmergencyTruncateStrategy()] const stashConfig = config?.stash const stashObj = typeof stashConfig === 'object' ? stashConfig : undefined this._stashStorage = stashConfig === false ? false : stashObj?.storage this._enableRetrievalTool = stashConfig !== false && stashObj?.retrievalTool !== false } + /** + * Resolves a `ContextManagerStrategy` value into a `ContextManager` instance. + * + * @param strategy - A preset string, config object, false, or undefined + * @returns A ContextManager for preset strings and configs; undefined for false/undefined + */ + static from(strategy: ContextManagerStrategy | undefined): ContextManager | undefined { + if (strategy === false || strategy === undefined) return undefined + if (strategy === 'auto') { + return new ContextManager() + } + if (strategy === 'agentic') { + return new ContextManager({ + strategies: [ + Offload.truncate('toolResults', { previewTokens: TRUNCATE_PREVIEW_TOKENS }).when({ + threshold: AGENTIC_TRUNCATE_THRESHOLD, + }), + Offload.summarize('*').when({ utilization: 1, preserveRecent: 4 }), + ], + }) + } + if (typeof strategy === 'string') { + throw new Error( + `Unknown contextManager preset: "${strategy}". Valid presets: ${CONTEXT_MANAGER_PRESETS.map((s) => `"${s}"`).join(', ')}` + ) + } + return new ContextManager(strategy) + } + getTools(): Tool[] { if (!this._enableRetrievalTool) return [] if (!this._stash) return [] @@ -109,7 +163,7 @@ export class ContextManager implements Plugin { return } - const acted = await this._runStrategies(event.agent) + const acted = await this._runStrategies(event.agent, undefined, true) if (!acted) { logger.warn(`agentId=<${event.agent.id}> | no strategy made progress, skipping retry`) return @@ -139,7 +193,11 @@ export class ContextManager implements Plugin { return this._stashIsDurable } - private async _runStrategies(agent: LocalAgent, precomputedInputTokens?: number): Promise { + private async _runStrategies( + agent: LocalAgent, + precomputedInputTokens?: number, + overflow?: boolean + ): Promise { const messages = agent.messages const inputTokens = precomputedInputTokens ?? (await agent.model.countTokens(messages)) @@ -147,12 +205,14 @@ export class ContextManager implements Plugin { messages, agent, utilization: agent.model.estimateUtilization(inputTokens), + ...(overflow ? { overflow: true } : {}), ...(this._stash ? { stash: this._stash } : {}), } let anyActed = false for (const strategy of this._strategies) { try { + if (strategy instanceof EmergencyTruncateStrategy && anyActed) strategyContext.overflow = false const acted = await strategy.apply(strategyContext) if (acted) { anyActed = true diff --git a/strands-ts/src/context-manager/presets.ts b/strands-ts/src/context-manager/presets.ts new file mode 100644 index 0000000000..46e5c521a9 --- /dev/null +++ b/strands-ts/src/context-manager/presets.ts @@ -0,0 +1,77 @@ +/** + * Strategy presets — named building blocks that resolve to concrete strategy configurations. + * + * Preset definitions (thresholds, methods, conditions) are internal defaults and may + * change between releases as we tune based on real-world usage data. Treat the preset + * name as the stable contract, not its expansion. Use raw `Offload.*` strategies when + * you need a specific, pinned configuration. + * + * @experimental + */ + +import type { ContextStrategy } from './types.js' +import { Offload } from './strategies/offload/index.js' + +/** + * Known preset name strings accepted in the strategies array. + * + * Preset names are the stable contract. The strategies they resolve to may change + * between releases. + * + * - `'proactiveSummarization'` — batch summarize oldest messages at 70% utilization + * - `'largeToolOffloading'` — truncate tool results over 2500 tokens to a 1000-token preview + * - `'overflowProtection'` — truncate oldest messages when the context window is full + * - `'staleToolCleanup'` — drop tool results older than 5 messages + */ +export const STRATEGY_PRESET_NAMES = [ + 'proactiveSummarization', + 'largeToolOffloading', + 'overflowProtection', + 'staleToolCleanup', +] as const + +/** String union of preset names. */ +export type StrategyPresetName = (typeof STRATEGY_PRESET_NAMES)[number] + +/** + * Resolves a preset name to its default strategy array. + * + * @param name - The preset name + * @returns The strategy array for the given preset + * @internal + */ +export function resolvePreset(name: StrategyPresetName): ContextStrategy[] { + // Defaults are tuning decisions, not API promises — adjust freely. + switch (name) { + case 'proactiveSummarization': + return [Offload.summarize('*').when({ utilization: 0.7, preserveRecent: 0.7 })] + case 'largeToolOffloading': + return [Offload.truncate('toolResults', { previewTokens: 1000 }).when({ threshold: 2500 })] + case 'overflowProtection': + return [Offload.truncate('*').when({ utilization: 1.0, preserveRecent: 4 })] + case 'staleToolCleanup': + return [Offload.drop('toolResults').when({ preserveRecent: 5 })] + } +} + +/** + * Resolves a mixed array of strategies and preset names into a flat strategy array. + * + * @param entries - Array of raw strategies and/or preset name strings + * @returns Flattened array of concrete strategies + * @internal + */ +export function resolveStrategies(entries: (ContextStrategy | StrategyPresetName)[]): ContextStrategy[] { + const strategies: ContextStrategy[] = [] + for (const entry of entries) { + if (typeof entry === 'string') { + if (!STRATEGY_PRESET_NAMES.includes(entry as StrategyPresetName)) { + throw new Error(`Unknown strategy preset: "${entry}". Valid presets: ${STRATEGY_PRESET_NAMES.join(', ')}`) + } + strategies.push(...resolvePreset(entry as StrategyPresetName)) + } else { + strategies.push(entry) + } + } + return strategies +} diff --git a/strands-ts/src/context-manager/strategies/__tests__/offload-strategy.test.ts b/strands-ts/src/context-manager/strategies/__tests__/offload-strategy.test.ts index 3176110379..084c89597a 100644 --- a/strands-ts/src/context-manager/strategies/__tests__/offload-strategy.test.ts +++ b/strands-ts/src/context-manager/strategies/__tests__/offload-strategy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { Offload } from '../offload/index.js' +import { getOldestMatches, buildToolNameMap } from '../offload/base.js' import { Message, TextBlock, ToolResultBlock, ToolUseBlock } from '../../../types/messages.js' import { createMockAgent } from '../../../__fixtures__/agent-helpers.js' import type { Agent } from '../../../agent/agent.js' @@ -37,7 +38,7 @@ function heuristicCountTokens(messages: Message[]): number { return total } -function makeContext(messages: Message[], utilization = 0.5): ContextState { +function makeContext(messages: Message[], utilization = 0.5, overflow?: boolean): ContextState { const agent = createMockAgent({ messages, extra: { model: { countTokens: async (msgs: Message[]) => heuristicCountTokens(msgs) } } as Partial, @@ -46,6 +47,7 @@ function makeContext(messages: Message[], utilization = 0.5): ContextState { messages, agent, utilization, + ...(overflow ? { overflow: true } : {}), } } @@ -593,9 +595,173 @@ describe('Message-level drop vs truncate markers', () => { await strategy.apply(context) - const markerMsg = messages.find((m) => m.content.some((b) => b instanceof TextBlock && b.text.includes('elided'))) - expect(markerMsg).toBeDefined() - const markerText = (markerMsg!.content[0] as TextBlock).text + const allText = messages.flatMap((m) => + m.content.filter((b) => b instanceof TextBlock).map((b) => (b as TextBlock).text) + ) + const markerText = allText.find((t) => t.includes('elided')) + expect(markerText).toBeDefined() expect(markerText).toMatch(/\[\.\.\. \d+ messages? elided \.\.\.\]/) }) }) + +describe('pinned message protection', () => { + it('message-level drop skips pinned messages', async () => { + const messages = [ + new Message({ role: 'user', content: [new TextBlock('q1')] }), + new Message({ role: 'assistant', content: [new TextBlock('a1')] }), + new Message({ + role: 'user', + content: [new TextBlock('pinned-msg')], + metadata: { custom: { pinned: true } }, + }), + new Message({ role: 'assistant', content: [new TextBlock('a2')] }), + new Message({ role: 'user', content: [new TextBlock('q3')] }), + new Message({ role: 'assistant', content: [new TextBlock('a3')] }), + ] + const strategy = Offload.drop('*').when({ utilization: 0.5 }) + const context = makeContext(messages, 0.9) + + await strategy.apply(context) + + const allText = messages.flatMap((message) => + message.content.filter((block) => block instanceof TextBlock).map((block) => (block as TextBlock).text) + ) + expect(allText).toContain('pinned-msg') + }) + + it('message-level drop skips tool-pair partner of pinned message', async () => { + const messages = [ + new Message({ role: 'user', content: [new TextBlock('q1')] }), + new Message({ + role: 'assistant', + content: [new ToolUseBlock({ toolUseId: 'tu-1', name: 'test', input: {} })], + metadata: { custom: { pinned: true } }, + }), + new Message({ + role: 'user', + content: [new ToolResultBlock({ toolUseId: 'tu-1', status: 'success', content: [new TextBlock('result')] })], + }), + new Message({ role: 'assistant', content: [new TextBlock('a2')] }), + new Message({ role: 'user', content: [new TextBlock('q3')] }), + new Message({ role: 'assistant', content: [new TextBlock('a3')] }), + ] + const strategy = Offload.drop('*').when({ utilization: 0.5 }) + const context = makeContext(messages, 0.9) + + await strategy.apply(context) + + const hasToolResult = messages.some((message) => + message.content.some((block) => block instanceof ToolResultBlock && block.toolUseId === 'tu-1') + ) + expect(hasToolResult).toBe(true) + }) + + it('pinned message survives repairAlternation merge across two passes', async () => { + // After pass 1 drops eligible messages, pinned-a3 becomes adjacent to a7 — + // repairAlternation must merge them and preserve the pinned metadata. + const messages = [ + new Message({ role: 'user', content: [new TextBlock('q1')] }), + new Message({ role: 'assistant', content: [new TextBlock('a1')] }), + new Message({ role: 'user', content: [new TextBlock('q2')] }), + new Message({ + role: 'assistant', + content: [new TextBlock('pinned-a3')], + metadata: { custom: { pinned: true } }, + }), + new Message({ role: 'user', content: [new TextBlock('q4')] }), + new Message({ role: 'assistant', content: [new TextBlock('a5')] }), + new Message({ role: 'user', content: [new TextBlock('q6')] }), + new Message({ role: 'assistant', content: [new TextBlock('a7')] }), + new Message({ role: 'user', content: [new TextBlock('q8')] }), + new Message({ role: 'assistant', content: [new TextBlock('a9')] }), + new Message({ role: 'user', content: [new TextBlock('q10')] }), + ] + const strategy = Offload.drop('*').when({ utilization: 0.5, preserveRecent: 4 }) + const context = makeContext(messages, 0.9) + + await strategy.apply(context) + await strategy.apply(context) + + const allText = messages.flatMap((message) => + message.content.filter((block) => block instanceof TextBlock).map((block) => (block as TextBlock).text) + ) + expect(allText.some((text) => text.includes('pinned-a3'))).toBe(true) + const merged = messages.find((message) => + message.content.some((block) => block instanceof TextBlock && (block as TextBlock).text === 'pinned-a3') + ) + expect(merged?.metadata?.custom?.pinned).toBe(true) + }) +}) + +describe('overflow bypass', () => { + it('message-level strategy fires on low utilization when overflow is set', async () => { + const messages = [ + new Message({ role: 'user', content: [new TextBlock('q1')] }), + new Message({ role: 'assistant', content: [new TextBlock('a1')] }), + new Message({ role: 'user', content: [new TextBlock('q2')] }), + new Message({ role: 'assistant', content: [new TextBlock('a2')] }), + ] + const strategy = Offload.drop('*').when({ utilization: 0.85 }) + const context = makeContext(messages, 0.5, true) + + const acted = await strategy.apply(context) + expect(acted).toBe(true) + expect(messages.length).toBeLessThan(4) + }) + + it('message-level strategy skips on low utilization without overflow', async () => { + const messages = [ + new Message({ role: 'user', content: [new TextBlock('q1')] }), + new Message({ role: 'assistant', content: [new TextBlock('a1')] }), + new Message({ role: 'user', content: [new TextBlock('q2')] }), + new Message({ role: 'assistant', content: [new TextBlock('a2')] }), + ] + const strategy = Offload.drop('*').when({ utilization: 0.85 }) + const context = makeContext(messages, 0.5) + + const acted = await strategy.apply(context) + expect(acted).toBe(false) + expect(messages.length).toBe(4) + }) +}) + +describe('getOldestMatches with ratio preserveRecent', () => { + function makeMessages(count: number): Message[] { + return Array.from( + { length: count }, + (_, index) => + new Message({ role: index % 2 === 0 ? 'user' : 'assistant', content: [new TextBlock(`msg-${index}`)] }) + ) + } + + it('integer preserveRecent keeps absolute count', () => { + const messages = makeMessages(10) + const toolNameMap = buildToolNameMap(messages) + const result = getOldestMatches(messages, '*', 3, toolNameMap, undefined, undefined) + expect(result).toHaveLength(7) + }) + + it('decimal preserveRecent keeps ratio of matches', () => { + const messages = makeMessages(10) + const toolNameMap = buildToolNameMap(messages) + // 0.7 = keep 70% of 10 = ceil(7) = 7 preserved, 3 returned + const result = getOldestMatches(messages, '*', 0.7, toolNameMap, undefined, undefined) + expect(result).toHaveLength(3) + }) + + it('decimal preserveRecent rounds up', () => { + const messages = makeMessages(3) + const toolNameMap = buildToolNameMap(messages) + // 0.5 of 3 = ceil(1.5) = 2 preserved, 1 returned + const result = getOldestMatches(messages, '*', 0.5, toolNameMap, undefined, undefined) + expect(result).toHaveLength(1) + }) + + it('decimal preserveRecent of 0.99 preserves almost all', () => { + const messages = makeMessages(10) + const toolNameMap = buildToolNameMap(messages) + // 0.99 of 10 = ceil(9.9) = 10 preserved, 0 returned + const result = getOldestMatches(messages, '*', 0.99, toolNameMap, undefined, undefined) + expect(result).toHaveLength(0) + }) +}) diff --git a/strands-ts/src/context-manager/strategies/offload/base.ts b/strands-ts/src/context-manager/strategies/offload/base.ts index 7f8df62801..b7a3442a1d 100644 --- a/strands-ts/src/context-manager/strategies/offload/base.ts +++ b/strands-ts/src/context-manager/strategies/offload/base.ts @@ -17,6 +17,7 @@ import type { LocalAgent } from '../../../types/agent.js' import type { ContextStrategy, ContextState } from '../../types.js' import type { Stash } from '../../stash.js' import { RETRIEVAL_TOOL_NAME } from '../../retrieval-tool.js' +import { isPinned } from '../../../conversation-manager/compression/pin-message.js' /** * Target for offload operations. This union is intentionally extensible — new @@ -54,7 +55,11 @@ export interface OffloadConditions { /** Context utilization ratio (0-1+) above which the strategy fires. */ utilization?: number - /** Number of most recent matching messages to leave untouched. */ + /** + * How many recent matching messages to leave untouched. + * - Integer (1 or above): absolute count of messages to preserve. + * - Decimal (between 0 and 1 exclusive): ratio of matching messages to preserve (e.g. 0.7 = keep 70%). + */ preserveRecent?: number } @@ -139,13 +144,15 @@ export function messageMatchesTarget( } /** - * Returns target-matching messages excluding the N most recent matches. - * First filters to only messages that match the target, then removes the last N from that set. + * Returns target-matching messages excluding the most recent matches. + * First filters to only messages that match the target, then removes the tail. + * + * @param preserveRecent - Integer (1 or above): absolute count. Decimal (between 0 and 1 exclusive): ratio of matches to keep. */ export function getOldestMatches( messages: Message[], target: OffloadTarget | undefined, - count: number, + preserveRecent: number, toolNameMap: Map, toolIncludeFilter: Set | undefined, toolExcludeFilter: Set | undefined @@ -153,6 +160,8 @@ export function getOldestMatches( const matching = messages.filter((message) => messageMatchesTarget(message, target, toolNameMap, toolIncludeFilter, toolExcludeFilter) ) + const count = + preserveRecent > 0 && preserveRecent < 1 ? Math.ceil(matching.length * preserveRecent) : Math.floor(preserveRecent) if (count >= matching.length) return [] return matching.slice(0, -count) } @@ -227,10 +236,12 @@ export function repairAlternation(messages: Message[]): void { const current = messages[readIndex]! if (writeIndex > 0 && messages[writeIndex - 1]!.role === current.role) { const prev = messages[writeIndex - 1]! + const pinned = prev.metadata?.custom?.pinned === true || current.metadata?.custom?.pinned === true messages[writeIndex - 1] = new Message({ role: prev.role, content: [...prev.content, ...current.content], trackingId: prev.trackingId, + ...(pinned ? { metadata: { custom: { pinned: true } } } : {}), }) } else { messages[writeIndex] = current @@ -285,7 +296,6 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { protected readonly _threshold: number | undefined protected readonly _utilizationThreshold: number | undefined protected readonly _preserveRecent: number - protected readonly _removalRatio: number = 0.3 protected readonly _includeFilter: Set | undefined protected readonly _excludeFilter: Set | undefined protected _stash: Stash | undefined @@ -298,7 +308,7 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { this._target = target this._threshold = finiteOrUndefined(conditions?.threshold) this._utilizationThreshold = finiteOrUndefined(conditions?.utilization) - this._preserveRecent = Math.floor(finiteOrUndefined(conditions?.preserveRecent) ?? 0) + this._preserveRecent = finiteOrUndefined(conditions?.preserveRecent) ?? 0 const resolved = resolveToolFilter(target) this._includeFilter = resolved.include @@ -316,6 +326,8 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { if (this._preserveRecent > 0) return agent.addHook(MessageAddedEvent, async (event) => { const messages = event.agent.messages + const index = messages.indexOf(event.message) + if (index >= 0 && isPinned(messages, index)) return const toolNameMap = buildToolNameMap(messages) await this._transformBlocks(event.message, messages, toolNameMap, event.agent) }) @@ -324,7 +336,7 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { async apply(context: ContextState): Promise { if (context.stash) this._stash = context.stash if (this._isMessageLevel) { - if (context.utilization < this._utilizationThreshold!) return false + if (!context.overflow && context.utilization < this._utilizationThreshold!) return false return this._applyPerMessage(context) } @@ -349,6 +361,8 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { let acted = false for (const message of eligible) { + const index = messages.indexOf(message) + if (isPinned(messages, index)) continue if (await this._transformBlocks(message, messages, toolNameMap, agent)) { acted = true } @@ -357,7 +371,7 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { return acted } - /** Message-level execution: remove oldest 30% of eligible messages with pair safety. */ + /** Message-level execution: remove eligible messages with pair safety. */ protected async _applyPerMessage(context: ContextState): Promise { const { messages } = context if (messages.length <= 1) return false @@ -365,10 +379,7 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { const eligible = await this._getEligibleMessages(context) if (eligible.length === 0) return false - const targetRemoval = Math.max(1, Math.floor(eligible.length * this._removalRatio)) - const toRemove = eligible.slice(0, targetRemoval) - - const { removed, lowestIndex } = spliceWithPairs(messages, toRemove) + const { removed, lowestIndex } = spliceWithPairs(messages, eligible) if (removed === 0) return false const marker = this._makeRemovalMarker(removed) @@ -443,11 +454,15 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { toolNameMap, this._includeFilter, this._excludeFilter - ).filter((message) => messages.indexOf(message) > 0) + ).filter((message) => { + const index = messages.indexOf(message) + return index > 0 && !isPinned(messages, index) + }) } else { candidates = messages.filter( (message, index) => index > 0 && + !isPinned(messages, index) && messageMatchesTarget(message, this._target, toolNameMap, this._includeFilter, this._excludeFilter) ) } @@ -483,6 +498,8 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { // --- Emergency truncation strategy --- +const EMERGENCY_REMOVAL_RATIO = 0.2 + /** * Last-resort strategy that recomputes utilization and drops the oldest 20% of messages * when the context window is still overflowing after all user-configured strategies have run. @@ -494,7 +511,6 @@ export abstract class BaseOffloadStrategy implements ContextStrategy { */ export class EmergencyTruncateStrategy extends BaseOffloadStrategy { readonly name = 'offload:emergency-truncate' - protected override readonly _removalRatio = 0.2 constructor() { super('*') @@ -506,10 +522,28 @@ export class EmergencyTruncateStrategy extends BaseOffloadStrategy { override async apply(context: ContextState): Promise { if (context.messages.length <= 3) return false - const tokens = await context.agent.model.countTokens(context.messages) - const utilization = context.agent.model.estimateUtilization(tokens) - if (utilization < 1.0) return false - return this._applyPerMessage({ ...context, utilization }) + if (!context.overflow) { + const tokens = await context.agent.model.countTokens(context.messages) + const utilization = context.agent.model.estimateUtilization(tokens) + if (utilization < 1.0) return false + } + return this._applyPerMessage(context) + } + + /** Drop the oldest 20% of non-head messages each pass. */ + protected override async _applyPerMessage(context: ContextState): Promise { + const { messages } = context + if (messages.length <= 3) return false + + const removable = messages.filter((_, index) => index > 0) + const removeCount = Math.max(1, Math.floor(removable.length * EMERGENCY_REMOVAL_RATIO)) + const toRemove = removable.slice(0, removeCount) + + const { removed } = spliceWithPairs(messages, toRemove) + if (removed === 0) return false + + repairAlternation(messages) + return true } protected async _replaceBlock(): Promise { diff --git a/strands-ts/src/context-manager/strategies/offload/summarize.ts b/strands-ts/src/context-manager/strategies/offload/summarize.ts index c3425dc155..625279e6b3 100644 --- a/strands-ts/src/context-manager/strategies/offload/summarize.ts +++ b/strands-ts/src/context-manager/strategies/offload/summarize.ts @@ -59,12 +59,9 @@ export class SummarizeStrategy extends BaseOffloadStrategy { const eligible = await this._getEligibleMessages(context) if (eligible.length === 0) return false - const summarizeCount = Math.max(1, Math.floor(eligible.length * this._removalRatio)) - const toSummarize = eligible.slice(0, summarizeCount) - // Expand to include paired messages so we don't orphan tool pairs const safeSet = new Set() - for (const message of toSummarize) { + for (const message of eligible) { const index = messages.indexOf(message) if (index === -1) continue for (const removable of collectRemovableWithPair(messages, index)) { diff --git a/strands-ts/src/context-manager/strategies/offload/truncate.ts b/strands-ts/src/context-manager/strategies/offload/truncate.ts index 9409afcf11..2322494b3e 100644 --- a/strands-ts/src/context-manager/strategies/offload/truncate.ts +++ b/strands-ts/src/context-manager/strategies/offload/truncate.ts @@ -58,16 +58,7 @@ export class TruncateStrategy extends BaseOffloadStrategy { const eligible = await this._getEligibleMessages(context) if (eligible.length === 0) return false - // Determine head/tail split based on config (default: favor tail — 30% head, 70% tail) - const previewMode = this._truncateConfig.preview ?? 'headTail' - const headShare = { head: 1, tail: 0, headTail: 0.3 }[previewMode] - const targetRemoval = Math.max(1, Math.floor(eligible.length * this._removalRatio)) - const keepCount = eligible.length - targetRemoval - - const headKeep = Math.floor(keepCount * headShare) - const tailKeep = keepCount - headKeep - - const middleMessages = eligible.slice(headKeep, eligible.length - tailKeep) + const middleMessages = eligible if (middleMessages.length === 0) return false diff --git a/strands-ts/src/context-manager/types.ts b/strands-ts/src/context-manager/types.ts index ffd4b0e3c2..096c19b02d 100644 --- a/strands-ts/src/context-manager/types.ts +++ b/strands-ts/src/context-manager/types.ts @@ -6,6 +6,7 @@ import type { Storage } from '../storage/storage.js' import type { Stash } from './stash.js' import type { LocalAgent } from '../types/agent.js' import type { Message } from '../types/messages.js' +import type { StrategyPresetName } from './presets.js' /** * A context reduction strategy that can offload, summarize, or otherwise @@ -48,6 +49,13 @@ export interface ContextState { /** Current context utilization ratio (0-1+). Above 1.0 means overflow. */ utilization: number + /** + * Set when running in response to a `ContextWindowOverflowError`. + * Strategies should bypass utilization gates when true — the provider + * already rejected the request, so the estimate is not trustworthy. + */ + overflow?: boolean + /** L1 stash for persisting offloaded content. Present when storage is configured. */ stash?: Stash } @@ -80,8 +88,11 @@ export interface ContextManagerConfig { * sees the output of the previous. Order determines priority — if two strategies * target the same content, the first one to shrink it below the next strategy's * threshold wins. When omitted, uses the default pipeline. + * + * Accepts raw `ContextStrategy` objects, preset name strings (e.g. `'largeToolOffloading'`), + * or a mix of both. Preset strings are resolved to their default strategy configurations. */ - strategies?: ContextStrategy[] + strategies?: (ContextStrategy | StrategyPresetName)[] /** * L1 stash configuration. The stash persists offloaded content so the agent can diff --git a/strands-ts/src/index.ts b/strands-ts/src/index.ts index ac3d2298bc..beeee943f3 100644 --- a/strands-ts/src/index.ts +++ b/strands-ts/src/index.ts @@ -276,6 +276,19 @@ export { type RetryDecision, } from './retry/index.js' +// Context Manager (experimental) +export type { ContextManagerStrategy } from './context-manager/context-manager.js' +export type { ContextManagerConfig, ContextStrategy, ContextState, StashConfig } from './context-manager/types.js' +export { Offload } from './context-manager/strategies/offload/index.js' +export type { + OffloadTarget, + OffloadConditions, + OffloadStrategyBuilder, +} from './context-manager/strategies/offload/index.js' +export type { TruncateConfig } from './context-manager/methods/truncate.js' +export type { SummarizeConfig } from './context-manager/methods/summarize.js' +export type { StrategyPresetName } from './context-manager/presets.js' + // Conversation Manager export { ConversationManager, diff --git a/strands-ts/src/session/__tests__/session-manager.test.ts b/strands-ts/src/session/__tests__/session-manager.test.ts index 064fa53057..cd360bb65c 100644 --- a/strands-ts/src/session/__tests__/session-manager.test.ts +++ b/strands-ts/src/session/__tests__/session-manager.test.ts @@ -1117,7 +1117,7 @@ describe('SessionManager — stash with real Agent wiring', () => { const agent = new Agent({ model: {} as any, storage, - contextManager: new ContextManager(), + contextManager: {}, sessionManager, printer: false, }) @@ -1136,7 +1136,7 @@ describe('SessionManager — stash with real Agent wiring', () => { const freshAgent = new Agent({ model: {} as any, storage, - contextManager: new ContextManager(), + contextManager: {}, sessionManager: freshSessionManager, printer: false, }) @@ -1157,7 +1157,7 @@ describe('SessionManager — stash with real Agent wiring', () => { const agent = new Agent({ model: {} as any, storage, - contextManager: new ContextManager(), + contextManager: {}, sessionManager, printer: false, }) diff --git a/strands-ts/src/types/agent.ts b/strands-ts/src/types/agent.ts index 5ca2b26104..38b3f5b59c 100644 --- a/strands-ts/src/types/agent.ts +++ b/strands-ts/src/types/agent.ts @@ -307,9 +307,7 @@ export interface LocalAgent { readonly storage?: Storage | undefined /** - * The context manager instance, when a {@link ContextManager} was passed to the agent. - * Undefined when no context manager is configured or when a string preset - * (`'auto'`, `'agentic'`) was used. + * The resolved context manager instance. Present when a preset or config was provided. * * @internal */