-
Notifications
You must be signed in to change notification settings - Fork 884
feat(context): add context_manager="auto" facade on Agent #2643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+389
−8
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
a5701e3
feat(strands-py): add context_manager="auto" facade on Agent
lizradway f011cec
feat(strands-py): enable proactive compression at 0.85 threshold
lizradway dfe1652
style: fix import sorting in _resolve_context_manager
lizradway 2ad0ea1
fix(strands-py): address PR review comments on context_manager facade
lizradway 51caed5
feat(strands-ts): add contextManager="auto" facade on Agent
lizradway 4f4196c
Merge remote-tracking branch 'upstream/main' into facade
lizradway ededd37
fix: address PR review — extract types, constants, and resolver function
lizradway 2654e07
fix: add TS runtime validation, align plugin ordering across SDKs
lizradway 872502d
fix: derive runtime validation from type in Python, simplify TS check
lizradway 619e694
fix: format TS test with prettier, fix stale docstring
lizradway 9988826
refactor: rename _CM_ constants to _CONTEXT_MANAGER_ for clarity
lizradway d9273f4
refactor: rename resolved_cm to resolved_conversation_manager
lizradway cd47802
docs: note InMemoryStorage limitation with session_manager in docstring
lizradway 4656d2a
refactor: rename CM_ constants and cm variables for clarity
lizradway ca8fbad
docs: add InMemoryStorage durability note to TS contextManager docstring
lizradway File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
102 changes: 102 additions & 0 deletions
102
strands-py/tests/strands/agent/test_agent_context_manager.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| """Tests for the context_manager parameter on Agent.""" | ||
|
|
||
| from unittest.mock import MagicMock | ||
|
|
||
| import pytest | ||
|
|
||
| from strands import Agent, Plugin | ||
| from strands.agent.conversation_manager import SlidingWindowConversationManager, SummarizingConversationManager | ||
| from strands.vended_plugins.context_offloader import ContextOffloader, InMemoryStorage | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def mock_model(): | ||
| model = MagicMock() | ||
| model.stateful = False | ||
| model.context_window_limit = 200_000 | ||
| return model | ||
|
|
||
|
|
||
| class TestContextManagerNone: | ||
| def test_default_preserves_sliding_window(self, mock_model): | ||
| agent = Agent(model=mock_model) | ||
| assert isinstance(agent.conversation_manager, SlidingWindowConversationManager) | ||
|
|
||
| def test_explicit_none_preserves_sliding_window(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager=None) | ||
| assert isinstance(agent.conversation_manager, SlidingWindowConversationManager) | ||
|
|
||
| def test_no_offloader_plugin_by_default(self, mock_model): | ||
| agent = Agent(model=mock_model) | ||
| assert "context_offloader" not in agent._plugin_registry._plugins | ||
|
|
||
|
|
||
| class TestContextManagerAuto: | ||
| def test_uses_summarizing_conversation_manager(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager="auto") | ||
| assert isinstance(agent.conversation_manager, SummarizingConversationManager) | ||
|
|
||
| def test_summary_ratio_is_benchmark_default(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager="auto") | ||
| assert agent.conversation_manager.summary_ratio == 0.3 | ||
|
|
||
| def test_proactive_compression_at_85_percent(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager="auto") | ||
| assert agent.conversation_manager._compression_threshold == 0.85 | ||
|
lizradway marked this conversation as resolved.
|
||
|
|
||
| def test_adds_context_offloader_plugin(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager="auto") | ||
| assert "context_offloader" in agent._plugin_registry._plugins | ||
|
|
||
| def test_offloader_max_result_tokens(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager="auto") | ||
| offloader = agent._plugin_registry._plugins["context_offloader"] | ||
| assert offloader._max_result_tokens == 1500 | ||
|
|
||
| def test_offloader_preview_tokens(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager="auto") | ||
| offloader = agent._plugin_registry._plugins["context_offloader"] | ||
| assert offloader._preview_tokens == 750 | ||
|
|
||
| def test_offloader_uses_in_memory_storage(self, mock_model): | ||
| agent = Agent(model=mock_model, context_manager="auto") | ||
| offloader = agent._plugin_registry._plugins["context_offloader"] | ||
| assert isinstance(offloader._storage, InMemoryStorage) | ||
|
|
||
|
|
||
| class TestContextManagerCoexistence: | ||
| def test_user_conversation_manager_is_respected(self, mock_model): | ||
| user_conversation_manager = SlidingWindowConversationManager(window_size=20) | ||
| agent = Agent(model=mock_model, context_manager="auto", conversation_manager=user_conversation_manager) | ||
| assert agent.conversation_manager is user_conversation_manager | ||
|
|
||
| def test_offloader_still_added_with_user_conversation_manager(self, mock_model): | ||
| user_conversation_manager = SlidingWindowConversationManager(window_size=20) | ||
| agent = Agent(model=mock_model, context_manager="auto", conversation_manager=user_conversation_manager) | ||
| assert "context_offloader" in agent._plugin_registry._plugins | ||
|
|
||
| def test_user_offloader_not_overridden(self, mock_model): | ||
| user_offloader = ContextOffloader(storage=MagicMock(), max_result_tokens=3000, preview_tokens=1000) | ||
| agent = Agent(model=mock_model, context_manager="auto", plugins=[user_offloader]) | ||
| assert agent._plugin_registry._plugins["context_offloader"]._max_result_tokens == 3000 | ||
|
|
||
| def test_user_plugins_preserved(self, mock_model): | ||
| class MyPlugin(Plugin): | ||
| name = "my_plugin" | ||
|
|
||
| plugin = MyPlugin() | ||
| agent = Agent(model=mock_model, context_manager="auto", plugins=[plugin]) | ||
| assert "my_plugin" in agent._plugin_registry._plugins | ||
| assert "context_offloader" in agent._plugin_registry._plugins | ||
|
|
||
|
|
||
| class TestContextManagerErrors: | ||
| def test_raises_with_stateful_model(self): | ||
| stateful_model = MagicMock() | ||
| stateful_model.stateful = True | ||
| with pytest.raises(ValueError, match="stateful model"): | ||
| Agent(model=stateful_model, context_manager="auto") | ||
|
|
||
| def test_raises_with_unsupported_value(self, mock_model): | ||
| with pytest.raises(ValueError, match="Unsupported context_manager value"): | ||
| Agent(model=mock_model, context_manager="manual") | ||
121 changes: 121 additions & 0 deletions
121
strands-ts/src/agent/__tests__/agent.context-manager.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| 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 { ContextOffloader } from '../../vended-plugins/context-offloader/plugin.js' | ||
| import { InMemoryStorage } from '../../vended-plugins/context-offloader/storage.js' | ||
| import type { ConversationManager } from '../../conversation-manager/conversation-manager.js' | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| function internals(agent: Agent): any { | ||
| return agent as any | ||
| } | ||
|
|
||
| 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', () => { | ||
| const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) | ||
| const agent = new Agent({ model }) | ||
| expect(getConversationManager(agent)).toBeInstanceOf(SlidingWindowConversationManager) | ||
| }) | ||
|
|
||
| it('does not add ContextOffloader plugin', () => { | ||
| 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() | ||
| }) | ||
| }) | ||
|
|
||
| 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', () => { | ||
| 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) | ||
| }) | ||
|
|
||
| it('enables proactive compression at 0.85', () => { | ||
| 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) | ||
| }) | ||
|
|
||
| it('adds ContextOffloader plugin with benchmark defaults', () => { | ||
| const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) | ||
| const agent = new Agent({ model, contextManager: 'auto' }) | ||
| const pending = getPending(agent) | ||
| const offloader = pending.find((p: any) => p.name === 'strands:context-offloader') as any | ||
| expect(offloader).toBeDefined() | ||
| expect(offloader._maxResultTokens).toBe(1500) | ||
| expect(offloader._previewTokens).toBe(750) | ||
| expect(offloader._storage).toBeInstanceOf(InMemoryStorage) | ||
| }) | ||
| }) | ||
|
|
||
| describe('coexistence with conversationManager', () => { | ||
| it('respects 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) | ||
| }) | ||
|
|
||
| it('still adds ContextOffloader when user provides 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 }) | ||
| const pending = getPending(agent) | ||
| expect(pending.find((p: any) => p.name === 'strands:context-offloader')).toBeDefined() | ||
| }) | ||
|
|
||
| it('does not add duplicate ContextOffloader if user provides one', () => { | ||
| const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'hi' }) | ||
| const userOffloader = new ContextOffloader({ | ||
| storage: new InMemoryStorage(), | ||
| 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) | ||
| }) | ||
| }) | ||
|
|
||
| describe('stateful model', () => { | ||
| it('throws when used with a stateful model', () => { | ||
| class StatefulModel extends MockMessageModel { | ||
| override get stateful(): boolean { | ||
| return true | ||
| } | ||
| } | ||
| const model = new StatefulModel().addTurn({ type: 'textBlock', text: 'hi' }) | ||
| expect(() => new Agent({ model, contextManager: 'auto' })).toThrow('stateful model') | ||
| }) | ||
| }) | ||
|
|
||
| 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') | ||
| }) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.