From fdac712a1b01b50a59e0ae3389b5d29e4d4ec7e2 Mon Sep 17 00:00:00 2001 From: Strands Agent Date: Mon, 5 Jan 2026 13:40:11 -0500 Subject: [PATCH 01/13] feat(agent): add configurable retry_strategy for model calls Refactored hardcoded retry logic in event_loop into a flexible, hook-based retry system that allows users to customize retry behavior. --- src/strands/agent/__init__.py | 4 + src/strands/agent/agent.py | 21 ++ src/strands/agent/retry.py | 212 +++++++++++++++++ src/strands/event_loop/event_loop.py | 47 ++-- tests/strands/agent/conftest.py | 22 ++ .../strands/agent/hooks/test_agent_events.py | 3 +- tests/strands/agent/test_agent_retry.py | 175 ++++++++++++++ tests/strands/agent/test_retry.py | 217 ++++++++++++++++++ tests/strands/event_loop/test_event_loop.py | 27 ++- 9 files changed, 692 insertions(+), 36 deletions(-) create mode 100644 src/strands/agent/retry.py create mode 100644 tests/strands/agent/conftest.py create mode 100644 tests/strands/agent/test_agent_retry.py create mode 100644 tests/strands/agent/test_retry.py diff --git a/src/strands/agent/__init__.py b/src/strands/agent/__init__.py index 6618d3328..78a87e00d 100644 --- a/src/strands/agent/__init__.py +++ b/src/strands/agent/__init__.py @@ -4,6 +4,7 @@ - Agent: The main interface for interacting with AI models and tools - ConversationManager: Classes for managing conversation history and context windows +- Retry Strategies: Configurable retry behavior for model calls """ from .agent import Agent @@ -14,6 +15,7 @@ SlidingWindowConversationManager, SummarizingConversationManager, ) +from .retry import ModelRetryStrategy, NoopRetryStrategy __all__ = [ "Agent", @@ -22,4 +24,6 @@ "NullConversationManager", "SlidingWindowConversationManager", "SummarizingConversationManager", + "ModelRetryStrategy", + "NoopRetryStrategy", ] diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 9e726ca0b..cf56cd459 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -124,6 +124,7 @@ def __init__( hooks: Optional[list[HookProvider]] = None, session_manager: Optional[SessionManager] = None, tool_executor: Optional[ToolExecutor] = None, + retry_strategy: Optional[HookProvider] = None, ): """Initialize the Agent with the specified configuration. @@ -173,6 +174,9 @@ def __init__( session_manager: Manager for handling agent sessions including conversation history and state. If provided, enables session-based persistence and state management. tool_executor: Definition of tool execution strategy (e.g., sequential, concurrent, etc.). + retry_strategy: Strategy for retrying model calls on throttling or other transient errors. + Defaults to ModelRetryStrategy with max_attempts=6, initial_delay=4s, max_delay=240s. + Pass NoopRetryStrategy to disable retries, or implement a custom HookProvider for custom retry logic. Raises: ValueError: If agent id contains path separators. @@ -245,6 +249,11 @@ def __init__( self._interrupt_state = _InterruptState() + # Initialize retry strategy + from .retry import ModelRetryStrategy + + self._retry_strategy = retry_strategy if retry_strategy is not None else ModelRetryStrategy() + # Initialize session management functionality self._session_manager = session_manager if self._session_manager: @@ -253,6 +262,9 @@ def __init__( # Allow conversation_managers to subscribe to hooks self.hooks.add_hook(self.conversation_manager) + # Register retry strategy as a hook + self.hooks.add_hook(self._retry_strategy) + self.tool_executor = tool_executor or ConcurrentToolExecutor() if hooks: @@ -289,6 +301,15 @@ def system_prompt(self, value: str | list[SystemContentBlock] | None) -> None: """ self._system_prompt, self._system_prompt_content = self._initialize_system_prompt(value) + @property + def retry_strategy(self) -> HookProvider: + """Get the retry strategy for this agent. + + Returns: + The retry strategy hook provider. + """ + return self._retry_strategy + @property def tool(self) -> _ToolCaller: """Call tool as a function. diff --git a/src/strands/agent/retry.py b/src/strands/agent/retry.py new file mode 100644 index 000000000..5d5467367 --- /dev/null +++ b/src/strands/agent/retry.py @@ -0,0 +1,212 @@ +"""Retry strategy implementations for handling model throttling and other retry scenarios. + +This module provides hook-based retry strategies that can be configured on the Agent +to control retry behavior for model invocations. Retry strategies implement the +HookProvider protocol and register callbacks for AfterModelCallEvent to determine +when and how to retry failed model calls. +""" + +import asyncio +import logging +from typing import Any + +from ..types.exceptions import ModelThrottledException +from ..hooks.events import AfterInvocationEvent, AfterModelCallEvent +from ..hooks.registry import HookProvider, HookRegistry + +logger = logging.getLogger(__name__) + + +class ModelRetryStrategy(HookProvider): + """Default retry strategy for model throttling with exponential backoff. + + This strategy implements automatic retry logic for model throttling exceptions, + using exponential backoff to handle rate limiting gracefully. It retries + model calls when ModelThrottledException is raised, up to a configurable + maximum number of attempts. + + The delay between retries starts at initial_delay and doubles after each + retry, up to a maximum of max_delay. The strategy automatically resets + its state after a successful model call. + + Example: + ```python + from strands import Agent + from strands.hooks import ModelRetryStrategy + + # Use custom retry parameters + retry_strategy = ModelRetryStrategy( + max_attempts=3, + initial_delay=2, + max_delay=60 + ) + agent = Agent(retry_strategy=retry_strategy) + ``` + + Attributes: + max_attempts: Maximum number of retry attempts before giving up. + initial_delay: Initial delay in seconds before the first retry. + max_delay: Maximum delay in seconds between retries. + current_attempt: Current retry attempt counter (resets on success). + current_delay: Current delay value for exponential backoff. + """ + + def __init__( + self, + max_attempts: int = 6, + initial_delay: int = 4, + max_delay: int = 240, + ): + """Initialize the retry strategy with the specified parameters. + + Args: + max_attempts: Maximum number of retry attempts. Defaults to 6. + initial_delay: Initial delay in seconds before retrying. Defaults to 4. + max_delay: Maximum delay in seconds between retries. Defaults to 240 (4 minutes). + """ + self._max_attempts = max_attempts + self._initial_delay = initial_delay + self._max_delay = max_delay + self._current_attempt = 0 + self._did_trigger_retry = False + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Register callbacks for AfterModelCallEvent and AfterInvocationEvent. + + Args: + registry: The hook registry to register callbacks with. + **kwargs: Additional keyword arguments for future extensibility. + """ + registry.add_callback(AfterModelCallEvent, self._handle_after_model_call) + registry.add_callback(AfterInvocationEvent, self._handle_after_invocation) + + def _calculate_delay(self) -> float: + """Calculate the current retry delay based on attempt number. + + Uses exponential backoff: initial_delay * (2 ** attempt), capped at max_delay. + + Returns: + The delay in seconds for the current attempt. + """ + if self._current_attempt == 0: + return self._initial_delay + delay = self._initial_delay * (2 ** (self._current_attempt - 1)) + return min(delay, self._max_delay) + + @property + def _current_delay(self) -> float: + """Get the current retry delay (for backwards compatibility with EventLoopThrottleEvent). + + This property is private and only exists for backwards compatibility with EventLoopThrottleEvent. + External code should not access this property. + """ + return self._calculate_delay() + + def _reset_retry_state(self) -> None: + """Reset retry state to initial values.""" + self._current_attempt = 0 + self._did_trigger_retry = False + + async def _handle_after_invocation(self, event: AfterInvocationEvent) -> None: + """Reset retry state after invocation completes. + + Args: + event: The AfterInvocationEvent signaling invocation completion. + """ + self._reset_retry_state() + + async def _handle_after_model_call(self, event: AfterModelCallEvent) -> None: + """Handle model call completion and determine if retry is needed. + + This callback is invoked after each model call. If the call failed with + a ModelThrottledException and we haven't exceeded max_attempts, it sets + event.retry to True and sleeps for the current delay before returning. + + On successful calls, it resets the retry state to prepare for future calls. + + Args: + event: The AfterModelCallEvent containing call results or exception. + """ + # If already retrying, skip processing (another hook may have triggered retry) + if event.retry: + return + + # If model call succeeded, reset retry state + if event.stop_response is not None: + logger.debug( + "stop_reason=<%s> | model call succeeded, resetting retry state", + event.stop_response.stop_reason, + ) + self._reset_retry_state() + return + + # Check if we have an exception and reset state if no exception + if event.exception is None: + self._reset_retry_state() + return + + # Only retry on ModelThrottledException + if not isinstance(event.exception, ModelThrottledException): + return + + # Increment attempt counter first + self._current_attempt += 1 + + # Check if we've exceeded max attempts + if self._current_attempt >= self._max_attempts: + logger.debug( + "current_attempt=<%d>, max_attempts=<%d> | max retry attempts reached, not retrying", + self._current_attempt, + self._max_attempts, + ) + self._did_trigger_retry = False + return + + # Calculate delay for this attempt + delay = self._calculate_delay() + + # Retry the model call + logger.debug( + "retry_delay_seconds=<%s>, max_attempts=<%s>, current_attempt=<%s> " + "| throttling exception encountered | delaying before next retry", + delay, + self._max_attempts, + self._current_attempt, + ) + + # Sleep for current delay + await asyncio.sleep(delay) + + # Set retry flag and track that this strategy triggered it + event.retry = True + self._did_trigger_retry = True + + +class NoopRetryStrategy(HookProvider): + """No-op retry strategy that disables automatic retries. + + This strategy can be used when you want to explicitly disable retry behavior + and handle errors directly in your application code. It implements the + HookProvider protocol but does not register any callbacks. + + Example: + ```python + from strands import Agent + from strands.hooks import NoopRetryStrategy + + # Disable automatic retries + agent = Agent(retry_strategy=NoopRetryStrategy()) + ``` + """ + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Register hooks (no-op implementation). + + This method intentionally does nothing, as this strategy disables retries. + + Args: + registry: The hook registry to register callbacks with. + **kwargs: Additional keyword arguments for future extensibility. + """ + # Intentionally empty - no callbacks to register + pass diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index fcb530a0d..6f1bfa41b 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -51,10 +51,6 @@ logger = logging.getLogger(__name__) -MAX_ATTEMPTS = 6 -INITIAL_DELAY = 4 -MAX_DELAY = 240 # 4 minutes - def _has_tool_use_in_latest_message(messages: "Messages") -> bool: """Check if the latest message contains any ToolUse content blocks. @@ -315,9 +311,9 @@ async def _handle_model_execution( stream_trace = Trace("stream_messages", parent_id=cycle_trace.id) cycle_trace.add_child(stream_trace) - # Retry loop for handling throttling exceptions - current_delay = INITIAL_DELAY - for attempt in range(MAX_ATTEMPTS): + # Retry loop - actual retry logic is handled by retry_strategy hook + # Hooks control when to stop retrying via the event.retry flag + while True: model_id = agent.model.config.get("model_id") if hasattr(agent.model, "config") else None model_invoke_span = tracer.start_model_invoke_span( messages=agent.messages, @@ -364,10 +360,14 @@ async def _handle_model_execution( # Check if hooks want to retry the model call if after_model_call_event.retry: logger.debug( - "stop_reason=<%s>, retry_requested=, attempt=<%d> | hook requested model retry", + "stop_reason=<%s>, retry_requested= | hook requested model retry", stop_reason, - attempt + 1, ) + # Emit EventLoopThrottleEvent for backwards compatibility if ModelRetryStrategy triggered retry + from ..agent.retry import ModelRetryStrategy + + if isinstance(agent.retry_strategy, ModelRetryStrategy) and agent.retry_strategy._did_trigger_retry: + yield EventLoopThrottleEvent(delay=agent.retry_strategy._current_delay) continue # Retry the model call if stop_reason == "max_tokens": @@ -390,31 +390,18 @@ async def _handle_model_execution( # Check if hooks want to retry the model call if after_model_call_event.retry: logger.debug( - "exception=<%s>, retry_requested=, attempt=<%d> | hook requested model retry", + "exception=<%s>, retry_requested= | hook requested model retry", type(e).__name__, - attempt + 1, ) - continue # Retry the model call - - if isinstance(e, ModelThrottledException): - if attempt + 1 == MAX_ATTEMPTS: - yield ForceStopEvent(reason=e) - raise e + # Emit EventLoopThrottleEvent for backwards compatibility if ModelRetryStrategy triggered retry + from ..agent.retry import ModelRetryStrategy - logger.debug( - "retry_delay_seconds=<%s>, max_attempts=<%s>, current_attempt=<%s> " - "| throttling exception encountered " - "| delaying before next retry", - current_delay, - MAX_ATTEMPTS, - attempt + 1, - ) - await asyncio.sleep(current_delay) - current_delay = min(current_delay * 2, MAX_DELAY) + if isinstance(agent.retry_strategy, ModelRetryStrategy) and agent.retry_strategy._did_trigger_retry: + yield EventLoopThrottleEvent(delay=agent.retry_strategy._current_delay) + continue # Retry the model call - yield EventLoopThrottleEvent(delay=current_delay) - else: - raise e + # No retry requested, raise the exception + raise e try: # Add message in trace and mark the end of the stream messages trace diff --git a/tests/strands/agent/conftest.py b/tests/strands/agent/conftest.py new file mode 100644 index 000000000..d3af90dc8 --- /dev/null +++ b/tests/strands/agent/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for agent tests.""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + + +@pytest.fixture +def mock_sleep(monkeypatch): + """Mock asyncio.sleep to avoid delays in tests and track sleep calls.""" + sleep_calls = [] + + async def _mock_sleep(delay): + sleep_calls.append(delay) + + mock = AsyncMock(side_effect=_mock_sleep) + monkeypatch.setattr(asyncio, "sleep", mock) + + # Return both the mock and the sleep_calls list for verification + mock.sleep_calls = sleep_calls + return mock diff --git a/tests/strands/agent/hooks/test_agent_events.py b/tests/strands/agent/hooks/test_agent_events.py index 7b189a5c6..2cc9a5420 100644 --- a/tests/strands/agent/hooks/test_agent_events.py +++ b/tests/strands/agent/hooks/test_agent_events.py @@ -513,7 +513,8 @@ async def test_event_loop_cycle_text_response_throttling_early_end( {"event_loop_throttled_delay": 32, **common_props}, {"event_loop_throttled_delay": 64, **common_props}, {"event_loop_throttled_delay": 128, **common_props}, - {"force_stop": True, "force_stop_reason": "ThrottlingException | ConverseStream"}, + # Note: force_stop event is no longer emitted with hook-based retry strategy + # The exception is raised after max attempts without emitting force_stop ] assert tru_events == exp_events diff --git a/tests/strands/agent/test_agent_retry.py b/tests/strands/agent/test_agent_retry.py new file mode 100644 index 000000000..d3fdf7939 --- /dev/null +++ b/tests/strands/agent/test_agent_retry.py @@ -0,0 +1,175 @@ +"""Integration tests for Agent retry_strategy parameter.""" + +from unittest.mock import Mock + +import pytest + +from strands import Agent +from strands.agent.retry import ModelRetryStrategy, NoopRetryStrategy +from strands.types.exceptions import ModelThrottledException +from tests.fixtures.mocked_model_provider import MockedModelProvider + + +# Agent Retry Strategy Initialization Tests + + +def test_agent_with_default_retry_strategy(): + """Test that Agent uses ModelRetryStrategy by default when retry_strategy=None.""" + agent = Agent() + + # Should have a retry_strategy + assert hasattr(agent, "retry_strategy") + assert agent.retry_strategy is not None + + # Should be ModelRetryStrategy with default parameters + assert isinstance(agent.retry_strategy, ModelRetryStrategy) + assert agent.retry_strategy._max_attempts == 6 + assert agent.retry_strategy._initial_delay == 4 + assert agent.retry_strategy._max_delay == 240 + + +def test_agent_with_custom_model_retry_strategy(): + """Test Agent initialization with custom ModelRetryStrategy parameters.""" + custom_strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + agent = Agent(retry_strategy=custom_strategy) + + assert agent.retry_strategy is custom_strategy + assert agent.retry_strategy._max_attempts == 3 + assert agent.retry_strategy._initial_delay == 2 + assert agent.retry_strategy._max_delay == 60 + + +def test_agent_with_noop_retry_strategy(): + """Test Agent initialization with NoopRetryStrategy.""" + noop_strategy = NoopRetryStrategy() + agent = Agent(retry_strategy=noop_strategy) + + assert agent.retry_strategy is noop_strategy + assert isinstance(agent.retry_strategy, NoopRetryStrategy) + + +def test_retry_strategy_registered_as_hook(): + """Test that retry_strategy is registered with the hook system.""" + custom_strategy = ModelRetryStrategy(max_attempts=3) + agent = Agent(retry_strategy=custom_strategy) + + # Verify retry strategy callback is registered + from strands.hooks import AfterModelCallEvent + + callbacks = list(agent.hooks.get_callbacks_for(AfterModelCallEvent(agent=agent, exception=None))) + + # Should have at least one callback (from retry strategy) + assert len(callbacks) > 0 + + # Verify one of the callbacks is from the retry strategy + assert any(callback.__self__ is custom_strategy if hasattr(callback, "__self__") else False for callback in callbacks) + + +# Agent Retry Behavior Tests + + +@pytest.mark.asyncio +async def test_agent_retries_with_default_strategy(mock_sleep): + """Test that Agent retries on throttling with default ModelRetryStrategy.""" + # Create a model that fails twice with throttling, then succeeds + model = Mock() + model.stream.side_effect = [ + ModelThrottledException("ThrottlingException"), + ModelThrottledException("ThrottlingException"), + MockedModelProvider([{"role": "assistant", "content": [{"text": "Success after retries"}]}]).stream([]), + ] + + agent = Agent(model=model) + + result = agent.stream_async("test prompt") + events = [event async for event in result] + + # Should have succeeded after retries - just check we got events + assert len(events) > 0 + + # Should have slept twice (for two retries) + assert len(mock_sleep.sleep_calls) == 2 + # First retry: 4 seconds + assert mock_sleep.sleep_calls[0] == 4 + # Second retry: 8 seconds (exponential backoff) + assert mock_sleep.sleep_calls[1] == 8 + + +@pytest.mark.asyncio +async def test_agent_no_retry_with_noop_strategy(): + """Test that Agent does not retry with NoopRetryStrategy.""" + # Create a model that always fails with throttling + model = Mock() + model.stream.side_effect = ModelThrottledException("ThrottlingException") + + agent = Agent(model=model, retry_strategy=NoopRetryStrategy()) + + # Should raise exception immediately without retry + with pytest.raises(ModelThrottledException): + result = agent.stream_async("test prompt") + # Consume the stream to trigger the exception + _ = [event async for event in result] + + +@pytest.mark.asyncio +async def test_agent_respects_max_attempts(mock_sleep): + """Test that Agent respects max_attempts in retry strategy.""" + # Create a model that always fails + model = Mock() + model.stream.side_effect = ModelThrottledException("ThrottlingException") + + # Use custom strategy with max 2 attempts + custom_strategy = ModelRetryStrategy(max_attempts=2, initial_delay=1, max_delay=60) + agent = Agent(model=model, retry_strategy=custom_strategy) + + with pytest.raises(ModelThrottledException): + result = agent.stream_async("test prompt") + _ = [event async for event in result] + + # Should have attempted max_attempts times, which means (max_attempts - 1) sleeps + # Attempt 0: fail, sleep + # Attempt 1: fail, no more attempts + assert len(mock_sleep.sleep_calls) == 1 + + +# Backwards Compatibility Tests + + +@pytest.mark.asyncio +async def test_event_loop_throttle_event_emitted(mock_sleep): + """Test that EventLoopThrottleEvent is still emitted for backwards compatibility.""" + # Create a model that fails once with throttling, then succeeds + model = Mock() + model.stream.side_effect = [ + ModelThrottledException("ThrottlingException"), + MockedModelProvider([{"role": "assistant", "content": [{"text": "Success"}]}]).stream([]), + ] + + agent = Agent(model=model) + + result = agent.stream_async("test prompt") + events = [event async for event in result] + + # Should have EventLoopThrottleEvent in the stream + throttle_events = [e for e in events if "event_loop_throttled_delay" in e] + assert len(throttle_events) > 0 + + # Should have the correct delay value + assert throttle_events[0]["event_loop_throttled_delay"] > 0 + + +@pytest.mark.asyncio +async def test_no_throttle_event_with_noop_strategy(): + """Test that EventLoopThrottleEvent is not emitted with NoopRetryStrategy.""" + # Create a model that succeeds immediately + model = MockedModelProvider([{"role": "assistant", "content": [{"text": "Success"}]}]) + + agent = Agent(model=model, retry_strategy=NoopRetryStrategy()) + + result = agent.stream_async("test prompt") + events = [event async for event in result] + + # Should not have any EventLoopThrottleEvent + throttle_events = [e for e in events if "event_loop_throttled_delay" in e] + assert len(throttle_events) == 0 + diff --git a/tests/strands/agent/test_retry.py b/tests/strands/agent/test_retry.py new file mode 100644 index 000000000..48fa8ab68 --- /dev/null +++ b/tests/strands/agent/test_retry.py @@ -0,0 +1,217 @@ +"""Unit tests for retry strategy implementations.""" + +from unittest.mock import Mock + +import pytest + +from strands.hooks import AfterModelCallEvent, HookRegistry +from strands.agent.retry import ModelRetryStrategy, NoopRetryStrategy +from strands.types.exceptions import ModelThrottledException + + +# ModelRetryStrategy Tests + + +def test_model_retry_strategy_init_with_defaults(): + """Test ModelRetryStrategy initialization with default parameters.""" + strategy = ModelRetryStrategy() + assert strategy._max_attempts == 6 + assert strategy._initial_delay == 4 + assert strategy._max_delay == 240 + assert strategy._current_attempt == 0 + assert strategy._calculate_delay() == 4 + + +def test_model_retry_strategy_init_with_custom_parameters(): + """Test ModelRetryStrategy initialization with custom parameters.""" + strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + assert strategy._max_attempts == 3 + assert strategy._initial_delay == 2 + assert strategy._max_delay == 60 + assert strategy._current_attempt == 0 + assert strategy._calculate_delay() == 2 + + +def test_model_retry_strategy_register_hooks(): + """Test that ModelRetryStrategy registers AfterModelCallEvent callback.""" + strategy = ModelRetryStrategy() + registry = HookRegistry() + + strategy.register_hooks(registry) + + # Verify callback was registered + assert AfterModelCallEvent in registry._registered_callbacks + assert len(registry._registered_callbacks[AfterModelCallEvent]) == 1 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_retry_on_throttle_exception_first_attempt(mock_sleep): + """Test retry behavior on first ModelThrottledException.""" + strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + mock_agent = Mock() + + event = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + + await strategy._handle_after_model_call(event) + + # Should set retry to True + assert event.retry is True + # Should sleep for initial_delay + assert mock_sleep.sleep_calls == [2] + # Should increment attempt + assert strategy._current_attempt == 1 + assert strategy._calculate_delay() == 4 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_exponential_backoff(mock_sleep): + """Test exponential backoff calculation.""" + strategy = ModelRetryStrategy(max_attempts=5, initial_delay=2, max_delay=16) + mock_agent = Mock() + + # Simulate multiple retries + for _ in range(4): + event = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + await strategy._handle_after_model_call(event) + assert event.retry is True + + # Verify exponential backoff with max_delay cap + # 2, 4, 8, 16 (capped) + assert mock_sleep.sleep_calls == [2, 4, 8, 16] + # Delay should be capped at max_delay + assert strategy._calculate_delay() == 16 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_no_retry_after_max_attempts(mock_sleep): + """Test that retry is not set after reaching max_attempts.""" + strategy = ModelRetryStrategy(max_attempts=2, initial_delay=2, max_delay=60) + mock_agent = Mock() + + # First attempt + event1 = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + await strategy._handle_after_model_call(event1) + assert event1.retry is True + assert strategy._current_attempt == 1 + + # Second attempt (at max_attempts) + event2 = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + await strategy._handle_after_model_call(event2) + # Should NOT retry after reaching max_attempts + assert event2.retry is False + assert strategy._current_attempt == 2 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_no_retry_on_non_throttle_exception(): + """Test that retry is not set for non-throttling exceptions.""" + strategy = ModelRetryStrategy() + mock_agent = Mock() + + event = AfterModelCallEvent( + agent=mock_agent, + exception=ValueError("Some other error"), + ) + + await strategy._handle_after_model_call(event) + + # Should not retry on non-throttling exceptions + assert event.retry is False + assert strategy._current_attempt == 0 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_no_retry_on_success(): + """Test that retry is not set when model call succeeds.""" + strategy = ModelRetryStrategy() + mock_agent = Mock() + + event = AfterModelCallEvent( + agent=mock_agent, + stop_response=AfterModelCallEvent.ModelStopResponse( + message={"role": "assistant", "content": [{"text": "Success"}]}, + stop_reason="end_turn", + ), + ) + + await strategy._handle_after_model_call(event) + + # Should not retry on success + assert event.retry is False + + +@pytest.mark.asyncio +async def test_model_retry_strategy_reset_on_success(mock_sleep): + """Test that strategy resets attempt counter on successful call.""" + strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + mock_agent = Mock() + + # First failure + event1 = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + await strategy._handle_after_model_call(event1) + assert event1.retry is True + assert strategy._current_attempt == 1 + + # Success - should reset + event2 = AfterModelCallEvent( + agent=mock_agent, + stop_response=AfterModelCallEvent.ModelStopResponse( + message={"role": "assistant", "content": [{"text": "Success"}]}, + stop_reason="end_turn", + ), + ) + await strategy._handle_after_model_call(event2) + assert event2.retry is False + # Should reset to initial state + assert strategy._current_attempt == 0 + assert strategy._calculate_delay() == 2 + + +# NoopRetryStrategy Tests + + +def test_noop_retry_strategy_register_hooks_does_nothing(): + """Test that NoopRetryStrategy does not register any callbacks.""" + strategy = NoopRetryStrategy() + registry = HookRegistry() + + strategy.register_hooks(registry) + + # Verify no callbacks were registered + assert len(registry._registered_callbacks) == 0 + + +@pytest.mark.asyncio +async def test_noop_retry_strategy_no_retry_on_throttle_exception(): + """Test that NoopRetryStrategy does not retry on throttle exceptions.""" + strategy = NoopRetryStrategy() + registry = HookRegistry() + strategy.register_hooks(registry) + + mock_agent = Mock() + event = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + + # Invoke callbacks (should be none registered) + await registry.invoke_callbacks_async(event) + + # event.retry should still be False (default) + assert event.retry is False + diff --git a/tests/strands/event_loop/test_event_loop.py b/tests/strands/event_loop/test_event_loop.py index 6b23bd592..17606c014 100644 --- a/tests/strands/event_loop/test_event_loop.py +++ b/tests/strands/event_loop/test_event_loop.py @@ -116,7 +116,13 @@ def tool_stream(tool): @pytest.fixture def hook_registry(): - return HookRegistry() + from strands.agent.retry import ModelRetryStrategy + + registry = HookRegistry() + # Register default retry strategy + retry_strategy = ModelRetryStrategy() + retry_strategy.register_hooks(registry) + return registry @pytest.fixture @@ -133,6 +139,8 @@ def tool_executor(): @pytest.fixture def agent(model, system_prompt, messages, tool_registry, thread_pool, hook_registry, tool_executor): + from strands.agent.retry import ModelRetryStrategy + mock = unittest.mock.Mock(name="agent") mock.__class__ = Agent mock.config.cache_points = [] @@ -147,6 +155,7 @@ def agent(model, system_prompt, messages, tool_registry, thread_pool, hook_regis mock.tool_executor = tool_executor mock._interrupt_state = _InterruptState() mock.trace_attributes = {} + mock.retry_strategy = ModelRetryStrategy() return mock @@ -692,7 +701,9 @@ async def test_event_loop_tracing_with_throttling_exception( ] # Mock the time.sleep function to speed up the test - with patch("strands.event_loop.event_loop.asyncio.sleep", new_callable=unittest.mock.AsyncMock): + import asyncio + + with patch.object(asyncio, "sleep", new_callable=unittest.mock.AsyncMock): stream = strands.event_loop.event_loop.event_loop_cycle( agent=agent, invocation_state={}, @@ -855,15 +866,21 @@ async def test_event_loop_cycle_exception_model_hooks(mock_sleep, agent, model, # 1st call - throttled assert next(events) == BeforeModelCallEvent(agent=agent) - assert next(events) == AfterModelCallEvent(agent=agent, stop_response=None, exception=exception) + expected_after = AfterModelCallEvent(agent=agent, stop_response=None, exception=exception) + expected_after.retry = True + assert next(events) == expected_after # 2nd call - throttled assert next(events) == BeforeModelCallEvent(agent=agent) - assert next(events) == AfterModelCallEvent(agent=agent, stop_response=None, exception=exception) + expected_after = AfterModelCallEvent(agent=agent, stop_response=None, exception=exception) + expected_after.retry = True + assert next(events) == expected_after # 3rd call - throttled assert next(events) == BeforeModelCallEvent(agent=agent) - assert next(events) == AfterModelCallEvent(agent=agent, stop_response=None, exception=exception) + expected_after = AfterModelCallEvent(agent=agent, stop_response=None, exception=exception) + expected_after.retry = True + assert next(events) == expected_after # 4th call - successful assert next(events) == BeforeModelCallEvent(agent=agent) From ef83070f4bd481d1974fc8927ebfd64575da9e42 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Mon, 5 Jan 2026 14:51:11 -0500 Subject: [PATCH 02/13] Yield backwards compatible events + update initial delay to be correct --- src/strands/agent/retry.py | 76 ++++++++----------- src/strands/event_loop/event_loop.py | 22 +++--- .../strands/agent/hooks/test_agent_events.py | 13 ++-- tests/strands/agent/test_agent_hooks.py | 2 +- tests/strands/agent/test_agent_retry.py | 6 +- tests/strands/agent/test_retry.py | 46 ++++++++--- tests/strands/event_loop/test_event_loop.py | 4 +- 7 files changed, 88 insertions(+), 81 deletions(-) diff --git a/src/strands/agent/retry.py b/src/strands/agent/retry.py index 5d5467367..020f429d8 100644 --- a/src/strands/agent/retry.py +++ b/src/strands/agent/retry.py @@ -10,9 +10,10 @@ import logging from typing import Any -from ..types.exceptions import ModelThrottledException from ..hooks.events import AfterInvocationEvent, AfterModelCallEvent from ..hooks.registry import HookProvider, HookRegistry +from ..types._events import EventLoopThrottleEvent, ForceStopEvent, TypedEvent +from ..types.exceptions import ModelThrottledException logger = logging.getLogger(__name__) @@ -20,14 +21,12 @@ class ModelRetryStrategy(HookProvider): """Default retry strategy for model throttling with exponential backoff. - This strategy implements automatic retry logic for model throttling exceptions, - using exponential backoff to handle rate limiting gracefully. It retries - model calls when ModelThrottledException is raised, up to a configurable - maximum number of attempts. + Retries model calls on ModelThrottledException using exponential backoff. + Delay doubles after each attempt: initial_delay, initial_delay*2, initial_delay*4, + etc., capped at max_delay. State resets after successful calls. - The delay between retries starts at initial_delay and doubles after each - retry, up to a maximum of max_delay. The strategy automatically resets - its state after a successful model call. + With defaults (initial_delay=4, max_delay=240, max_attempts=6), delays are: + 4s → 8s → 16s → 32s → 64s (5 retries before giving up on the 6th attempt). Example: ```python @@ -43,32 +42,32 @@ class ModelRetryStrategy(HookProvider): agent = Agent(retry_strategy=retry_strategy) ``` - Attributes: - max_attempts: Maximum number of retry attempts before giving up. - initial_delay: Initial delay in seconds before the first retry. - max_delay: Maximum delay in seconds between retries. - current_attempt: Current retry attempt counter (resets on success). - current_delay: Current delay value for exponential backoff. + Args: + max_attempts: Total model attempts before re-raising the exception. + initial_delay: Base delay in seconds; used for first two retries, then doubles. + max_delay: Upper bound in seconds for the exponential backoff. """ def __init__( self, + *, max_attempts: int = 6, initial_delay: int = 4, max_delay: int = 240, ): - """Initialize the retry strategy with the specified parameters. + """Initialize the retry strategy. Args: - max_attempts: Maximum number of retry attempts. Defaults to 6. - initial_delay: Initial delay in seconds before retrying. Defaults to 4. - max_delay: Maximum delay in seconds between retries. Defaults to 240 (4 minutes). + max_attempts: Total model attempts before re-raising the exception. Defaults to 6. + initial_delay: Base delay in seconds; used for first two retries, then doubles. + Defaults to 4. + max_delay: Upper bound in seconds for the exponential backoff. Defaults to 240. """ self._max_attempts = max_attempts self._initial_delay = initial_delay self._max_delay = max_delay self._current_attempt = 0 - self._did_trigger_retry = False + self._backwards_compatible_event_to_yield: TypedEvent | None = None def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: """Register callbacks for AfterModelCallEvent and AfterInvocationEvent. @@ -80,36 +79,25 @@ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: registry.add_callback(AfterModelCallEvent, self._handle_after_model_call) registry.add_callback(AfterInvocationEvent, self._handle_after_invocation) - def _calculate_delay(self) -> float: - """Calculate the current retry delay based on attempt number. - - Uses exponential backoff: initial_delay * (2 ** attempt), capped at max_delay. - + def _calculate_delay(self, attempt: int) -> int: + """Calculate retry delay using exponential backoff. + + Args: + attempt: The attempt number (0-indexed) to calculate delay for. + Returns: - The delay in seconds for the current attempt. + Delay in seconds for the given attempt. """ - if self._current_attempt == 0: - return self._initial_delay - delay = self._initial_delay * (2 ** (self._current_attempt - 1)) + delay: int = self._initial_delay * (2**attempt) return min(delay, self._max_delay) - @property - def _current_delay(self) -> float: - """Get the current retry delay (for backwards compatibility with EventLoopThrottleEvent). - - This property is private and only exists for backwards compatibility with EventLoopThrottleEvent. - External code should not access this property. - """ - return self._calculate_delay() - def _reset_retry_state(self) -> None: """Reset retry state to initial values.""" self._current_attempt = 0 - self._did_trigger_retry = False async def _handle_after_invocation(self, event: AfterInvocationEvent) -> None: """Reset retry state after invocation completes. - + Args: event: The AfterInvocationEvent signaling invocation completion. """ @@ -127,6 +115,10 @@ async def _handle_after_model_call(self, event: AfterModelCallEvent) -> None: Args: event: The AfterModelCallEvent containing call results or exception. """ + delay = self._calculate_delay(self._current_attempt) + + self._backwards_compatible_event_to_yield = None + # If already retrying, skip processing (another hook may have triggered retry) if event.retry: return @@ -159,11 +151,10 @@ async def _handle_after_model_call(self, event: AfterModelCallEvent) -> None: self._current_attempt, self._max_attempts, ) - self._did_trigger_retry = False + self._backwards_compatible_event_to_yield = ForceStopEvent(reason=event.exception) return - # Calculate delay for this attempt - delay = self._calculate_delay() + self._backwards_compatible_event_to_yield = EventLoopThrottleEvent(delay=delay) # Retry the model call logger.debug( @@ -179,7 +170,6 @@ async def _handle_after_model_call(self, event: AfterModelCallEvent) -> None: # Set retry flag and track that this strategy triggered it event.retry = True - self._did_trigger_retry = True class NoopRetryStrategy(HookProvider): diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index 6f1bfa41b..014e9b0f3 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -8,7 +8,6 @@ 4. Manage recursive execution cycles """ -import asyncio import logging import uuid from typing import TYPE_CHECKING, Any, AsyncGenerator @@ -22,7 +21,6 @@ from ..tools.structured_output._structured_output_context import StructuredOutputContext from ..types._events import ( EventLoopStopEvent, - EventLoopThrottleEvent, ForceStopEvent, ModelMessageEvent, ModelStopReason, @@ -38,7 +36,6 @@ ContextWindowOverflowException, EventLoopException, MaxTokensReachedException, - ModelThrottledException, StructuredOutputException, ) from ..types.streaming import StopReason @@ -363,11 +360,6 @@ async def _handle_model_execution( "stop_reason=<%s>, retry_requested= | hook requested model retry", stop_reason, ) - # Emit EventLoopThrottleEvent for backwards compatibility if ModelRetryStrategy triggered retry - from ..agent.retry import ModelRetryStrategy - - if isinstance(agent.retry_strategy, ModelRetryStrategy) and agent.retry_strategy._did_trigger_retry: - yield EventLoopThrottleEvent(delay=agent.retry_strategy._current_delay) continue # Retry the model call if stop_reason == "max_tokens": @@ -387,17 +379,23 @@ async def _handle_model_execution( ) await agent.hooks.invoke_callbacks_async(after_model_call_event) + # Emit backwards-compatible events if retry strategy supports it + # (prior to making the retry strategy configurable, this is what we emitted) + from ..agent import ModelRetryStrategy + + if ( + isinstance(agent.retry_strategy, ModelRetryStrategy) + and agent.retry_strategy._backwards_compatible_event_to_yield + ): + yield agent.retry_strategy._backwards_compatible_event_to_yield + # Check if hooks want to retry the model call if after_model_call_event.retry: logger.debug( "exception=<%s>, retry_requested= | hook requested model retry", type(e).__name__, ) - # Emit EventLoopThrottleEvent for backwards compatibility if ModelRetryStrategy triggered retry - from ..agent.retry import ModelRetryStrategy - if isinstance(agent.retry_strategy, ModelRetryStrategy) and agent.retry_strategy._did_trigger_retry: - yield EventLoopThrottleEvent(delay=agent.retry_strategy._current_delay) continue # Retry the model call # No retry requested, raise the exception diff --git a/tests/strands/agent/hooks/test_agent_events.py b/tests/strands/agent/hooks/test_agent_events.py index 2cc9a5420..05e39f283 100644 --- a/tests/strands/agent/hooks/test_agent_events.py +++ b/tests/strands/agent/hooks/test_agent_events.py @@ -1,6 +1,6 @@ import asyncio import unittest.mock -from unittest.mock import ANY, MagicMock, call +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch import pytest from pydantic import BaseModel @@ -34,9 +34,7 @@ async def streaming_tool(): @pytest.fixture def mock_sleep(): - with unittest.mock.patch.object( - strands.event_loop.event_loop.asyncio, "sleep", new_callable=unittest.mock.AsyncMock - ) as mock: + with patch.object(strands.agent.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock @@ -359,8 +357,8 @@ async def test_stream_e2e_throttle_and_redact(alist, mock_sleep): {"arg1": 1013, "init_event_loop": True}, {"start": True}, {"start_event_loop": True}, + {"event_loop_throttled_delay": 4, **throttle_props}, {"event_loop_throttled_delay": 8, **throttle_props}, - {"event_loop_throttled_delay": 16, **throttle_props}, {"event": {"messageStart": {"role": "assistant"}}}, {"event": {"redactContent": {"redactUserContentMessage": "BLOCKED!"}}}, {"event": {"contentBlockStart": {"start": {}}}}, @@ -508,13 +506,12 @@ async def test_event_loop_cycle_text_response_throttling_early_end( {"init_event_loop": True, "arg1": 1013}, {"start": True}, {"start_event_loop": True}, + {"event_loop_throttled_delay": 4, **common_props}, {"event_loop_throttled_delay": 8, **common_props}, {"event_loop_throttled_delay": 16, **common_props}, {"event_loop_throttled_delay": 32, **common_props}, {"event_loop_throttled_delay": 64, **common_props}, - {"event_loop_throttled_delay": 128, **common_props}, - # Note: force_stop event is no longer emitted with hook-based retry strategy - # The exception is raised after max attempts without emitting force_stop + {"force_stop": True, "force_stop_reason": "ThrottlingException | ConverseStream"}, ] assert tru_events == exp_events diff --git a/tests/strands/agent/test_agent_hooks.py b/tests/strands/agent/test_agent_hooks.py index 00b9d368a..9b0e36216 100644 --- a/tests/strands/agent/test_agent_hooks.py +++ b/tests/strands/agent/test_agent_hooks.py @@ -104,7 +104,7 @@ class User(BaseModel): @pytest.fixture def mock_sleep(): - with patch.object(strands.event_loop.event_loop.asyncio, "sleep", new_callable=AsyncMock) as mock: + with patch.object(strands.agent.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock diff --git a/tests/strands/agent/test_agent_retry.py b/tests/strands/agent/test_agent_retry.py index d3fdf7939..f43a62187 100644 --- a/tests/strands/agent/test_agent_retry.py +++ b/tests/strands/agent/test_agent_retry.py @@ -9,7 +9,6 @@ from strands.types.exceptions import ModelThrottledException from tests.fixtures.mocked_model_provider import MockedModelProvider - # Agent Retry Strategy Initialization Tests @@ -62,7 +61,9 @@ def test_retry_strategy_registered_as_hook(): assert len(callbacks) > 0 # Verify one of the callbacks is from the retry strategy - assert any(callback.__self__ is custom_strategy if hasattr(callback, "__self__") else False for callback in callbacks) + assert any( + callback.__self__ is custom_strategy if hasattr(callback, "__self__") else False for callback in callbacks + ) # Agent Retry Behavior Tests @@ -172,4 +173,3 @@ async def test_no_throttle_event_with_noop_strategy(): # Should not have any EventLoopThrottleEvent throttle_events = [e for e in events if "event_loop_throttled_delay" in e] assert len(throttle_events) == 0 - diff --git a/tests/strands/agent/test_retry.py b/tests/strands/agent/test_retry.py index 48fa8ab68..b9a694edf 100644 --- a/tests/strands/agent/test_retry.py +++ b/tests/strands/agent/test_retry.py @@ -4,11 +4,10 @@ import pytest -from strands.hooks import AfterModelCallEvent, HookRegistry from strands.agent.retry import ModelRetryStrategy, NoopRetryStrategy +from strands.hooks import AfterModelCallEvent, HookRegistry from strands.types.exceptions import ModelThrottledException - # ModelRetryStrategy Tests @@ -19,7 +18,6 @@ def test_model_retry_strategy_init_with_defaults(): assert strategy._initial_delay == 4 assert strategy._max_delay == 240 assert strategy._current_attempt == 0 - assert strategy._calculate_delay() == 4 def test_model_retry_strategy_init_with_custom_parameters(): @@ -29,7 +27,31 @@ def test_model_retry_strategy_init_with_custom_parameters(): assert strategy._initial_delay == 2 assert strategy._max_delay == 60 assert strategy._current_attempt == 0 - assert strategy._calculate_delay() == 2 + + +def test_model_retry_strategy_calculate_delay_with_different_attempts(): + """Test _calculate_delay returns correct exponential backoff for different attempt numbers.""" + strategy = ModelRetryStrategy(initial_delay=2, max_delay=32) + + # Test exponential backoff: 2 * (2^attempt) + assert strategy._calculate_delay(0) == 2 # 2 * 2^0 = 2 + assert strategy._calculate_delay(1) == 4 # 2 * 2^1 = 4 + assert strategy._calculate_delay(2) == 8 # 2 * 2^2 = 8 + assert strategy._calculate_delay(3) == 16 # 2 * 2^3 = 16 + assert strategy._calculate_delay(4) == 32 # 2 * 2^4 = 32 (at max) + assert strategy._calculate_delay(5) == 32 # 2 * 2^5 = 64, capped at 32 + assert strategy._calculate_delay(10) == 32 # Large attempt, still capped + + +def test_model_retry_strategy_calculate_delay_respects_max_delay(): + """Test _calculate_delay respects max_delay cap.""" + strategy = ModelRetryStrategy(initial_delay=10, max_delay=50) + + assert strategy._calculate_delay(0) == 10 # 10 * 2^0 = 10 + assert strategy._calculate_delay(1) == 20 # 10 * 2^1 = 20 + assert strategy._calculate_delay(2) == 40 # 10 * 2^2 = 40 + assert strategy._calculate_delay(3) == 50 # 10 * 2^3 = 80, capped at 50 + assert strategy._calculate_delay(4) == 50 # 10 * 2^4 = 160, capped at 50 def test_model_retry_strategy_register_hooks(): @@ -59,11 +81,11 @@ async def test_model_retry_strategy_retry_on_throttle_exception_first_attempt(mo # Should set retry to True assert event.retry is True - # Should sleep for initial_delay + # Should sleep for initial_delay (attempt 0: 2 * 2^0 = 2) assert mock_sleep.sleep_calls == [2] + assert mock_sleep.sleep_calls[0] == strategy._calculate_delay(0) # Should increment attempt assert strategy._current_attempt == 1 - assert strategy._calculate_delay() == 4 @pytest.mark.asyncio @@ -82,10 +104,10 @@ async def test_model_retry_strategy_exponential_backoff(mock_sleep): assert event.retry is True # Verify exponential backoff with max_delay cap - # 2, 4, 8, 16 (capped) + # attempt 0: 2*2^0=2, attempt 1: 2*2^1=4, attempt 2: 2*2^2=8, attempt 3: 2*2^3=16 (capped) assert mock_sleep.sleep_calls == [2, 4, 8, 16] - # Delay should be capped at max_delay - assert strategy._calculate_delay() == 16 + for i, sleep_delay in enumerate(mock_sleep.sleep_calls): + assert sleep_delay == strategy._calculate_delay(i) @pytest.mark.asyncio @@ -166,6 +188,9 @@ async def test_model_retry_strategy_reset_on_success(mock_sleep): await strategy._handle_after_model_call(event1) assert event1.retry is True assert strategy._current_attempt == 1 + # Should sleep for initial_delay (attempt 0: 2 * 2^0 = 2) + assert mock_sleep.sleep_calls == [2] + assert mock_sleep.sleep_calls[0] == strategy._calculate_delay(0) # Success - should reset event2 = AfterModelCallEvent( @@ -179,7 +204,7 @@ async def test_model_retry_strategy_reset_on_success(mock_sleep): assert event2.retry is False # Should reset to initial state assert strategy._current_attempt == 0 - assert strategy._calculate_delay() == 2 + assert strategy._calculate_delay(0) == 2 # NoopRetryStrategy Tests @@ -214,4 +239,3 @@ async def test_noop_retry_strategy_no_retry_on_throttle_exception(): # event.retry should still be False (default) assert event.retry is False - diff --git a/tests/strands/event_loop/test_event_loop.py b/tests/strands/event_loop/test_event_loop.py index 17606c014..2fe5227a8 100644 --- a/tests/strands/event_loop/test_event_loop.py +++ b/tests/strands/event_loop/test_event_loop.py @@ -31,9 +31,7 @@ @pytest.fixture def mock_sleep(): - with unittest.mock.patch.object( - strands.event_loop.event_loop.asyncio, "sleep", new_callable=unittest.mock.AsyncMock - ) as mock: + with patch.object(strands.agent.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock From 9caab19f9b36622292d27869020dfd0fe96e763e Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Mon, 5 Jan 2026 16:24:04 -0500 Subject: [PATCH 03/13] Always emit a ForceStopEvent when an exception bubbles --- src/strands/__init__.py | 3 ++ src/strands/agent/retry.py | 3 +- src/strands/event_loop/event_loop.py | 1 + .../strands/agent/hooks/test_agent_events.py | 40 +++++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/strands/__init__.py b/src/strands/__init__.py index 3718a29c5..7ae21dace 100644 --- a/src/strands/__init__.py +++ b/src/strands/__init__.py @@ -2,6 +2,7 @@ from . import agent, models, telemetry, types from .agent.agent import Agent +from .agent.retry import ModelRetryStrategy, NoopRetryStrategy from .tools.decorator import tool from .types.tools import ToolContext @@ -9,6 +10,8 @@ "Agent", "agent", "models", + "ModelRetryStrategy", + "NoopRetryStrategy", "tool", "ToolContext", "types", diff --git a/src/strands/agent/retry.py b/src/strands/agent/retry.py index 020f429d8..d7afaa7d7 100644 --- a/src/strands/agent/retry.py +++ b/src/strands/agent/retry.py @@ -12,7 +12,7 @@ from ..hooks.events import AfterInvocationEvent, AfterModelCallEvent from ..hooks.registry import HookProvider, HookRegistry -from ..types._events import EventLoopThrottleEvent, ForceStopEvent, TypedEvent +from ..types._events import EventLoopThrottleEvent, TypedEvent from ..types.exceptions import ModelThrottledException logger = logging.getLogger(__name__) @@ -151,7 +151,6 @@ async def _handle_after_model_call(self, event: AfterModelCallEvent) -> None: self._current_attempt, self._max_attempts, ) - self._backwards_compatible_event_to_yield = ForceStopEvent(reason=event.exception) return self._backwards_compatible_event_to_yield = EventLoopThrottleEvent(delay=delay) diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index 014e9b0f3..1899af7a0 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -399,6 +399,7 @@ async def _handle_model_execution( continue # Retry the model call # No retry requested, raise the exception + yield ForceStopEvent(reason=e) raise e try: diff --git a/tests/strands/agent/hooks/test_agent_events.py b/tests/strands/agent/hooks/test_agent_events.py index 05e39f283..d13e85270 100644 --- a/tests/strands/agent/hooks/test_agent_events.py +++ b/tests/strands/agent/hooks/test_agent_events.py @@ -8,6 +8,7 @@ import strands from strands import Agent from strands.agent import AgentResult +from strands.agent.retry import NoopRetryStrategy from strands.models import BedrockModel from strands.types._events import TypedEvent from strands.types.exceptions import ModelThrottledException @@ -525,6 +526,45 @@ async def test_event_loop_cycle_text_response_throttling_early_end( assert typed_events == [] +@pytest.mark.asyncio +async def test_event_loop_cycle_noop_retry_strategy_no_throttle_events( + agenerator, + alist, +): + """Test that NoopRetryStrategy emits no throttle events and raises immediately.""" + model = MagicMock() + model.stream.side_effect = [ + ModelThrottledException("ThrottlingException | ConverseStream"), + ] + + mock_callback = unittest.mock.Mock() + with pytest.raises(ModelThrottledException): + agent = Agent(model=model, callback_handler=mock_callback, retry_strategy=NoopRetryStrategy()) + + # Because we're throwing an exception, we manually collect the items here + tru_events = [] + stream = agent.stream_async("Do the stuff", arg1=1013) + async for event in stream: + tru_events.append(event) + + exp_events = [ + {"init_event_loop": True, "arg1": 1013}, + {"start": True}, + {"start_event_loop": True}, + {"force_stop": True, "force_stop_reason": "ThrottlingException | ConverseStream"}, + ] + + assert tru_events == exp_events + + exp_calls = [call(**event) for event in exp_events] + act_calls = mock_callback.call_args_list + assert act_calls == exp_calls + + # Ensure that all events coming out of the agent are *not* typed events + typed_events = [event for event in tru_events if isinstance(event, TypedEvent)] + assert typed_events == [] + + @pytest.mark.asyncio async def test_structured_output(agenerator): # we use bedrock here as it uses the tool implementation From fb78e58c8ec3e46faee6fe1f8cabde6a0ecb0375 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Mon, 5 Jan 2026 16:29:53 -0500 Subject: [PATCH 04/13] Condense the doc strings down --- src/strands/agent/retry.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/strands/agent/retry.py b/src/strands/agent/retry.py index d7afaa7d7..4adb6e302 100644 --- a/src/strands/agent/retry.py +++ b/src/strands/agent/retry.py @@ -28,20 +28,6 @@ class ModelRetryStrategy(HookProvider): With defaults (initial_delay=4, max_delay=240, max_attempts=6), delays are: 4s → 8s → 16s → 32s → 64s (5 retries before giving up on the 6th attempt). - Example: - ```python - from strands import Agent - from strands.hooks import ModelRetryStrategy - - # Use custom retry parameters - retry_strategy = ModelRetryStrategy( - max_attempts=3, - initial_delay=2, - max_delay=60 - ) - agent = Agent(retry_strategy=retry_strategy) - ``` - Args: max_attempts: Total model attempts before re-raising the exception. initial_delay: Base delay in seconds; used for first two retries, then doubles. @@ -177,15 +163,6 @@ class NoopRetryStrategy(HookProvider): This strategy can be used when you want to explicitly disable retry behavior and handle errors directly in your application code. It implements the HookProvider protocol but does not register any callbacks. - - Example: - ```python - from strands import Agent - from strands.hooks import NoopRetryStrategy - - # Disable automatic retries - agent = Agent(retry_strategy=NoopRetryStrategy()) - ``` """ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: From bfc71ab6a72ebf60531d5d5c218c4bce82b71976 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Tue, 6 Jan 2026 09:54:12 -0500 Subject: [PATCH 05/13] Remove NoopRetryStrategy --- src/strands/__init__.py | 3 +- src/strands/agent/__init__.py | 3 +- src/strands/agent/agent.py | 2 +- src/strands/agent/retry.py | 21 --------- .../strands/agent/hooks/test_agent_events.py | 40 ----------------- tests/strands/agent/test_agent_retry.py | 43 +------------------ tests/strands/agent/test_retry.py | 36 +--------------- 7 files changed, 5 insertions(+), 143 deletions(-) diff --git a/src/strands/__init__.py b/src/strands/__init__.py index 7ae21dace..8e7e4899a 100644 --- a/src/strands/__init__.py +++ b/src/strands/__init__.py @@ -2,7 +2,7 @@ from . import agent, models, telemetry, types from .agent.agent import Agent -from .agent.retry import ModelRetryStrategy, NoopRetryStrategy +from .agent.retry import ModelRetryStrategy from .tools.decorator import tool from .types.tools import ToolContext @@ -11,7 +11,6 @@ "agent", "models", "ModelRetryStrategy", - "NoopRetryStrategy", "tool", "ToolContext", "types", diff --git a/src/strands/agent/__init__.py b/src/strands/agent/__init__.py index 78a87e00d..b5706cac6 100644 --- a/src/strands/agent/__init__.py +++ b/src/strands/agent/__init__.py @@ -15,7 +15,7 @@ SlidingWindowConversationManager, SummarizingConversationManager, ) -from .retry import ModelRetryStrategy, NoopRetryStrategy +from .retry import ModelRetryStrategy __all__ = [ "Agent", @@ -25,5 +25,4 @@ "SlidingWindowConversationManager", "SummarizingConversationManager", "ModelRetryStrategy", - "NoopRetryStrategy", ] diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index cf56cd459..3fdbbfa89 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -176,7 +176,7 @@ def __init__( tool_executor: Definition of tool execution strategy (e.g., sequential, concurrent, etc.). retry_strategy: Strategy for retrying model calls on throttling or other transient errors. Defaults to ModelRetryStrategy with max_attempts=6, initial_delay=4s, max_delay=240s. - Pass NoopRetryStrategy to disable retries, or implement a custom HookProvider for custom retry logic. + Implement a custom HookProvider for custom retry logic, or pass None to disable retries. Raises: ValueError: If agent id contains path separators. diff --git a/src/strands/agent/retry.py b/src/strands/agent/retry.py index 4adb6e302..04a6101b8 100644 --- a/src/strands/agent/retry.py +++ b/src/strands/agent/retry.py @@ -155,24 +155,3 @@ async def _handle_after_model_call(self, event: AfterModelCallEvent) -> None: # Set retry flag and track that this strategy triggered it event.retry = True - - -class NoopRetryStrategy(HookProvider): - """No-op retry strategy that disables automatic retries. - - This strategy can be used when you want to explicitly disable retry behavior - and handle errors directly in your application code. It implements the - HookProvider protocol but does not register any callbacks. - """ - - def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: - """Register hooks (no-op implementation). - - This method intentionally does nothing, as this strategy disables retries. - - Args: - registry: The hook registry to register callbacks with. - **kwargs: Additional keyword arguments for future extensibility. - """ - # Intentionally empty - no callbacks to register - pass diff --git a/tests/strands/agent/hooks/test_agent_events.py b/tests/strands/agent/hooks/test_agent_events.py index d13e85270..05e39f283 100644 --- a/tests/strands/agent/hooks/test_agent_events.py +++ b/tests/strands/agent/hooks/test_agent_events.py @@ -8,7 +8,6 @@ import strands from strands import Agent from strands.agent import AgentResult -from strands.agent.retry import NoopRetryStrategy from strands.models import BedrockModel from strands.types._events import TypedEvent from strands.types.exceptions import ModelThrottledException @@ -526,45 +525,6 @@ async def test_event_loop_cycle_text_response_throttling_early_end( assert typed_events == [] -@pytest.mark.asyncio -async def test_event_loop_cycle_noop_retry_strategy_no_throttle_events( - agenerator, - alist, -): - """Test that NoopRetryStrategy emits no throttle events and raises immediately.""" - model = MagicMock() - model.stream.side_effect = [ - ModelThrottledException("ThrottlingException | ConverseStream"), - ] - - mock_callback = unittest.mock.Mock() - with pytest.raises(ModelThrottledException): - agent = Agent(model=model, callback_handler=mock_callback, retry_strategy=NoopRetryStrategy()) - - # Because we're throwing an exception, we manually collect the items here - tru_events = [] - stream = agent.stream_async("Do the stuff", arg1=1013) - async for event in stream: - tru_events.append(event) - - exp_events = [ - {"init_event_loop": True, "arg1": 1013}, - {"start": True}, - {"start_event_loop": True}, - {"force_stop": True, "force_stop_reason": "ThrottlingException | ConverseStream"}, - ] - - assert tru_events == exp_events - - exp_calls = [call(**event) for event in exp_events] - act_calls = mock_callback.call_args_list - assert act_calls == exp_calls - - # Ensure that all events coming out of the agent are *not* typed events - typed_events = [event for event in tru_events if isinstance(event, TypedEvent)] - assert typed_events == [] - - @pytest.mark.asyncio async def test_structured_output(agenerator): # we use bedrock here as it uses the tool implementation diff --git a/tests/strands/agent/test_agent_retry.py b/tests/strands/agent/test_agent_retry.py index f43a62187..5d1547019 100644 --- a/tests/strands/agent/test_agent_retry.py +++ b/tests/strands/agent/test_agent_retry.py @@ -5,7 +5,7 @@ import pytest from strands import Agent -from strands.agent.retry import ModelRetryStrategy, NoopRetryStrategy +from strands.agent.retry import ModelRetryStrategy from strands.types.exceptions import ModelThrottledException from tests.fixtures.mocked_model_provider import MockedModelProvider @@ -38,15 +38,6 @@ def test_agent_with_custom_model_retry_strategy(): assert agent.retry_strategy._max_delay == 60 -def test_agent_with_noop_retry_strategy(): - """Test Agent initialization with NoopRetryStrategy.""" - noop_strategy = NoopRetryStrategy() - agent = Agent(retry_strategy=noop_strategy) - - assert agent.retry_strategy is noop_strategy - assert isinstance(agent.retry_strategy, NoopRetryStrategy) - - def test_retry_strategy_registered_as_hook(): """Test that retry_strategy is registered with the hook system.""" custom_strategy = ModelRetryStrategy(max_attempts=3) @@ -96,22 +87,6 @@ async def test_agent_retries_with_default_strategy(mock_sleep): assert mock_sleep.sleep_calls[1] == 8 -@pytest.mark.asyncio -async def test_agent_no_retry_with_noop_strategy(): - """Test that Agent does not retry with NoopRetryStrategy.""" - # Create a model that always fails with throttling - model = Mock() - model.stream.side_effect = ModelThrottledException("ThrottlingException") - - agent = Agent(model=model, retry_strategy=NoopRetryStrategy()) - - # Should raise exception immediately without retry - with pytest.raises(ModelThrottledException): - result = agent.stream_async("test prompt") - # Consume the stream to trigger the exception - _ = [event async for event in result] - - @pytest.mark.asyncio async def test_agent_respects_max_attempts(mock_sleep): """Test that Agent respects max_attempts in retry strategy.""" @@ -157,19 +132,3 @@ async def test_event_loop_throttle_event_emitted(mock_sleep): # Should have the correct delay value assert throttle_events[0]["event_loop_throttled_delay"] > 0 - - -@pytest.mark.asyncio -async def test_no_throttle_event_with_noop_strategy(): - """Test that EventLoopThrottleEvent is not emitted with NoopRetryStrategy.""" - # Create a model that succeeds immediately - model = MockedModelProvider([{"role": "assistant", "content": [{"text": "Success"}]}]) - - agent = Agent(model=model, retry_strategy=NoopRetryStrategy()) - - result = agent.stream_async("test prompt") - events = [event async for event in result] - - # Should not have any EventLoopThrottleEvent - throttle_events = [e for e in events if "event_loop_throttled_delay" in e] - assert len(throttle_events) == 0 diff --git a/tests/strands/agent/test_retry.py b/tests/strands/agent/test_retry.py index b9a694edf..87b28f9aa 100644 --- a/tests/strands/agent/test_retry.py +++ b/tests/strands/agent/test_retry.py @@ -4,7 +4,7 @@ import pytest -from strands.agent.retry import ModelRetryStrategy, NoopRetryStrategy +from strands.agent.retry import ModelRetryStrategy from strands.hooks import AfterModelCallEvent, HookRegistry from strands.types.exceptions import ModelThrottledException @@ -205,37 +205,3 @@ async def test_model_retry_strategy_reset_on_success(mock_sleep): # Should reset to initial state assert strategy._current_attempt == 0 assert strategy._calculate_delay(0) == 2 - - -# NoopRetryStrategy Tests - - -def test_noop_retry_strategy_register_hooks_does_nothing(): - """Test that NoopRetryStrategy does not register any callbacks.""" - strategy = NoopRetryStrategy() - registry = HookRegistry() - - strategy.register_hooks(registry) - - # Verify no callbacks were registered - assert len(registry._registered_callbacks) == 0 - - -@pytest.mark.asyncio -async def test_noop_retry_strategy_no_retry_on_throttle_exception(): - """Test that NoopRetryStrategy does not retry on throttle exceptions.""" - strategy = NoopRetryStrategy() - registry = HookRegistry() - strategy.register_hooks(registry) - - mock_agent = Mock() - event = AfterModelCallEvent( - agent=mock_agent, - exception=ModelThrottledException("Throttled"), - ) - - # Invoke callbacks (should be none registered) - await registry.invoke_callbacks_async(event) - - # event.retry should still be False (default) - assert event.retry is False From d154c66cf1ea8f9624808f8b59d404f3103f0876 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Wed, 14 Jan 2026 14:10:54 -0500 Subject: [PATCH 06/13] Adds strict type validation for Agent retry strategy Enforces that Agent only accepts ModelRetryStrategy instances (not subclasses) for the retry_strategy parameter to prevent API confusion before a base RetryStrategy class is introduced. --- src/strands/agent/agent.py | 18 +++- src/strands/event_loop/event_loop.py | 4 + tests/strands/agent/test_agent_retry.py | 31 ++++++ tests/strands/agent/test_retry.py | 132 +++++++++++++++++++++++- 4 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 54c909073..56de05674 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -31,8 +31,9 @@ from .. import _identifier from .._async import run_async -from ..event_loop.event_loop import event_loop_cycle +from ..event_loop.event_loop import INITIAL_DELAY, MAX_ATTEMPTS, MAX_DELAY, event_loop_cycle from ..tools._tool_helpers import generate_missing_tool_result_content +from .retry import ModelRetryStrategy if TYPE_CHECKING: from ..experimental.tools import ToolProvider @@ -125,7 +126,7 @@ def __init__( hooks: Optional[list[HookProvider]] = None, session_manager: Optional[SessionManager] = None, tool_executor: Optional[ToolExecutor] = None, - retry_strategy: Optional[HookProvider] = None, + retry_strategy: Optional[ModelRetryStrategy] = None, ): """Initialize the Agent with the specified configuration. @@ -255,10 +256,17 @@ def __init__( # separate event loops in different threads, so asyncio.Lock wouldn't work self._invocation_lock = threading.Lock() - # Initialize retry strategy - from .retry import ModelRetryStrategy + # In the future, we'll have a RetryStrategy base class but until + # that API is determined we only allow ModelRetryStrategy + if retry_strategy and type(retry_strategy) is not ModelRetryStrategy: + raise ValueError("retry_strategy must be an instance of ModelRetryStrategy") + + self._retry_strategy = ( + retry_strategy + if retry_strategy is not None + else ModelRetryStrategy(max_attempts=MAX_ATTEMPTS, max_delay=MAX_DELAY, initial_delay=INITIAL_DELAY) + ) - self._retry_strategy = retry_strategy if retry_strategy is not None else ModelRetryStrategy() # Initialize session management functionality self._session_manager = session_manager if self._session_manager: diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index bd46b6320..b12962848 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -48,6 +48,10 @@ logger = logging.getLogger(__name__) +MAX_ATTEMPTS = 6 +INITIAL_DELAY = 4 +MAX_DELAY = 240 # 4 minutes + def _has_tool_use_in_latest_message(messages: "Messages") -> bool: """Check if the latest message contains any ToolUse content blocks. diff --git a/tests/strands/agent/test_agent_retry.py b/tests/strands/agent/test_agent_retry.py index 5d1547019..a900d0f1f 100644 --- a/tests/strands/agent/test_agent_retry.py +++ b/tests/strands/agent/test_agent_retry.py @@ -38,6 +38,37 @@ def test_agent_with_custom_model_retry_strategy(): assert agent.retry_strategy._max_delay == 60 +def test_agent_rejects_invalid_retry_strategy_type(): + """Test that Agent raises ValueError for non-ModelRetryStrategy retry_strategy.""" + + class FakeRetryStrategy: + pass + + with pytest.raises(ValueError, match="retry_strategy must be an instance of ModelRetryStrategy"): + Agent(retry_strategy=FakeRetryStrategy()) + + +def test_agent_rejects_subclass_of_model_retry_strategy(): + """Test that Agent rejects subclasses of ModelRetryStrategy (strict type check).""" + + class CustomRetryStrategy(ModelRetryStrategy): + pass + + with pytest.raises(ValueError, match="retry_strategy must be an instance of ModelRetryStrategy"): + Agent(retry_strategy=CustomRetryStrategy()) + + +def test_agent_default_retry_strategy_uses_event_loop_constants(): + """Test that default retry strategy uses constants from event_loop module.""" + from strands.event_loop.event_loop import INITIAL_DELAY, MAX_ATTEMPTS, MAX_DELAY + + agent = Agent() + + assert agent.retry_strategy._max_attempts == MAX_ATTEMPTS + assert agent.retry_strategy._initial_delay == INITIAL_DELAY + assert agent.retry_strategy._max_delay == MAX_DELAY + + def test_retry_strategy_registered_as_hook(): """Test that retry_strategy is registered with the hook system.""" custom_strategy = ModelRetryStrategy(max_attempts=3) diff --git a/tests/strands/agent/test_retry.py b/tests/strands/agent/test_retry.py index 87b28f9aa..07819b14e 100644 --- a/tests/strands/agent/test_retry.py +++ b/tests/strands/agent/test_retry.py @@ -55,16 +55,22 @@ def test_model_retry_strategy_calculate_delay_respects_max_delay(): def test_model_retry_strategy_register_hooks(): - """Test that ModelRetryStrategy registers AfterModelCallEvent callback.""" + """Test that ModelRetryStrategy registers AfterModelCallEvent and AfterInvocationEvent callbacks.""" + from strands.hooks import AfterInvocationEvent + strategy = ModelRetryStrategy() registry = HookRegistry() strategy.register_hooks(registry) - # Verify callback was registered + # Verify AfterModelCallEvent callback was registered assert AfterModelCallEvent in registry._registered_callbacks assert len(registry._registered_callbacks[AfterModelCallEvent]) == 1 + # Verify AfterInvocationEvent callback was registered + assert AfterInvocationEvent in registry._registered_callbacks + assert len(registry._registered_callbacks[AfterInvocationEvent]) == 1 + @pytest.mark.asyncio async def test_model_retry_strategy_retry_on_throttle_exception_first_attempt(mock_sleep): @@ -205,3 +211,125 @@ async def test_model_retry_strategy_reset_on_success(mock_sleep): # Should reset to initial state assert strategy._current_attempt == 0 assert strategy._calculate_delay(0) == 2 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_skips_if_already_retrying(): + """Test that strategy skips processing if event.retry is already True.""" + strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + mock_agent = Mock() + + event = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + # Simulate another hook already set retry to True + event.retry = True + + await strategy._handle_after_model_call(event) + + # Should not modify state since another hook already triggered retry + assert strategy._current_attempt == 0 + assert event.retry is True + + +@pytest.mark.asyncio +async def test_model_retry_strategy_reset_on_after_invocation(): + """Test that strategy resets state on AfterInvocationEvent.""" + from strands.hooks import AfterInvocationEvent + + strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + mock_agent = Mock() + + # Simulate some retry attempts + strategy._current_attempt = 3 + + event = AfterInvocationEvent(agent=mock_agent, result=Mock()) + await strategy._handle_after_invocation(event) + + # Should reset to initial state + assert strategy._current_attempt == 0 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_backwards_compatible_event_set_on_retry(mock_sleep): + """Test that _backwards_compatible_event_to_yield is set when retrying.""" + from strands.types._events import EventLoopThrottleEvent + + strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + mock_agent = Mock() + + event = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + + await strategy._handle_after_model_call(event) + + # Should have set the backwards compatible event + assert strategy._backwards_compatible_event_to_yield is not None + assert isinstance(strategy._backwards_compatible_event_to_yield, EventLoopThrottleEvent) + assert strategy._backwards_compatible_event_to_yield["event_loop_throttled_delay"] == 2 + + +@pytest.mark.asyncio +async def test_model_retry_strategy_backwards_compatible_event_cleared_on_success(): + """Test that _backwards_compatible_event_to_yield is cleared on success.""" + from strands.types._events import EventLoopThrottleEvent + + strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) + mock_agent = Mock() + + # Set a previous backwards compatible event + strategy._backwards_compatible_event_to_yield = EventLoopThrottleEvent(delay=2) + + event = AfterModelCallEvent( + agent=mock_agent, + stop_response=AfterModelCallEvent.ModelStopResponse( + message={"role": "assistant", "content": [{"text": "Success"}]}, + stop_reason="end_turn", + ), + ) + + await strategy._handle_after_model_call(event) + + # Should have cleared the backwards compatible event + assert strategy._backwards_compatible_event_to_yield is None + + +@pytest.mark.asyncio +async def test_model_retry_strategy_backwards_compatible_event_not_set_on_max_attempts(mock_sleep): + """Test that _backwards_compatible_event_to_yield is not set when max attempts reached.""" + strategy = ModelRetryStrategy(max_attempts=1, initial_delay=2, max_delay=60) + mock_agent = Mock() + + event = AfterModelCallEvent( + agent=mock_agent, + exception=ModelThrottledException("Throttled"), + ) + + await strategy._handle_after_model_call(event) + + # Should not have set the backwards compatible event since max attempts reached + assert strategy._backwards_compatible_event_to_yield is None + assert event.retry is False + + +@pytest.mark.asyncio +async def test_model_retry_strategy_no_retry_when_no_exception_and_no_stop_response(): + """Test that retry is not set when there's no exception and no stop_response.""" + strategy = ModelRetryStrategy() + mock_agent = Mock() + + # Event with neither exception nor stop_response + event = AfterModelCallEvent( + agent=mock_agent, + exception=None, + stop_response=None, + ) + + await strategy._handle_after_model_call(event) + + # Should not retry and should reset state + assert event.retry is False + assert strategy._current_attempt == 0 From 6ea2fe23b449acf86e3bb64caa4fe24b1a878c58 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Thu, 15 Jan 2026 12:43:57 -0500 Subject: [PATCH 07/13] fix: move imports to top --- tests/strands/agent/test_retry.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/strands/agent/test_retry.py b/tests/strands/agent/test_retry.py index 07819b14e..4ff33a289 100644 --- a/tests/strands/agent/test_retry.py +++ b/tests/strands/agent/test_retry.py @@ -5,7 +5,8 @@ import pytest from strands.agent.retry import ModelRetryStrategy -from strands.hooks import AfterModelCallEvent, HookRegistry +from strands.hooks import AfterInvocationEvent, AfterModelCallEvent, HookRegistry +from strands.types._events import EventLoopThrottleEvent from strands.types.exceptions import ModelThrottledException # ModelRetryStrategy Tests @@ -56,8 +57,6 @@ def test_model_retry_strategy_calculate_delay_respects_max_delay(): def test_model_retry_strategy_register_hooks(): """Test that ModelRetryStrategy registers AfterModelCallEvent and AfterInvocationEvent callbacks.""" - from strands.hooks import AfterInvocationEvent - strategy = ModelRetryStrategy() registry = HookRegistry() @@ -236,8 +235,6 @@ async def test_model_retry_strategy_skips_if_already_retrying(): @pytest.mark.asyncio async def test_model_retry_strategy_reset_on_after_invocation(): """Test that strategy resets state on AfterInvocationEvent.""" - from strands.hooks import AfterInvocationEvent - strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) mock_agent = Mock() @@ -254,8 +251,6 @@ async def test_model_retry_strategy_reset_on_after_invocation(): @pytest.mark.asyncio async def test_model_retry_strategy_backwards_compatible_event_set_on_retry(mock_sleep): """Test that _backwards_compatible_event_to_yield is set when retrying.""" - from strands.types._events import EventLoopThrottleEvent - strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) mock_agent = Mock() @@ -275,8 +270,6 @@ async def test_model_retry_strategy_backwards_compatible_event_set_on_retry(mock @pytest.mark.asyncio async def test_model_retry_strategy_backwards_compatible_event_cleared_on_success(): """Test that _backwards_compatible_event_to_yield is cleared on success.""" - from strands.types._events import EventLoopThrottleEvent - strategy = ModelRetryStrategy(max_attempts=3, initial_delay=2, max_delay=60) mock_agent = Mock() From 14dc7e8c767ac5cb0beddabb074c7d6518847ee2 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Thu, 15 Jan 2026 12:47:04 -0500 Subject: [PATCH 08/13] Tweaks after merge --- src/strands/agent/agent.py | 2 +- tests/strands/agent/test_agent.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 06b8d794c..89c51ec4e 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -119,7 +119,7 @@ def __init__( hooks: list[HookProvider] | None = None, session_manager: SessionManager | None = None, tool_executor: ToolExecutor | None = None, - retry_strategy: Optional[ModelRetryStrategy] = None, + retry_strategy: ModelRetryStrategy | None = None, ): """Initialize the Agent with the specified configuration. diff --git a/tests/strands/agent/test_agent.py b/tests/strands/agent/test_agent.py index 81ce65989..df6f1f10d 100644 --- a/tests/strands/agent/test_agent.py +++ b/tests/strands/agent/test_agent.py @@ -8,7 +8,8 @@ import time import unittest.mock import warnings -from typing import Any, AsyncGenerator +from collections.abc import AsyncGenerator +from typing import Any from uuid import uuid4 import pytest From 21d688fffbbe9e5188fe31f9c6e994684e08fed3 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Thu, 15 Jan 2026 15:58:01 -0500 Subject: [PATCH 09/13] Move retry functionality into event_loop though still exposed at top level --- src/strands/__init__.py | 2 +- src/strands/agent/__init__.py | 2 +- src/strands/agent/agent.py | 2 +- src/strands/event_loop/event_loop.py | 2 +- src/strands/{agent => event_loop}/retry.py | 0 tests/strands/agent/hooks/test_agent_events.py | 2 +- tests/strands/agent/test_agent_hooks.py | 2 +- tests/strands/agent/test_agent_retry.py | 3 +-- tests/strands/agent/test_retry.py | 2 +- tests/strands/event_loop/test_event_loop.py | 6 +++--- 10 files changed, 11 insertions(+), 12 deletions(-) rename src/strands/{agent => event_loop}/retry.py (100%) diff --git a/src/strands/__init__.py b/src/strands/__init__.py index 7dc4edc6d..ffc888a93 100644 --- a/src/strands/__init__.py +++ b/src/strands/__init__.py @@ -3,7 +3,7 @@ from . import agent, models, telemetry, types from .agent.agent import Agent from .agent.base import AgentBase -from .agent.retry import ModelRetryStrategy +from .event_loop.retry import ModelRetryStrategy from .tools.decorator import tool from .types.tools import ToolContext diff --git a/src/strands/agent/__init__.py b/src/strands/agent/__init__.py index 58b80dba0..6febef5ce 100644 --- a/src/strands/agent/__init__.py +++ b/src/strands/agent/__init__.py @@ -16,7 +16,7 @@ SlidingWindowConversationManager, SummarizingConversationManager, ) -from .retry import ModelRetryStrategy +from ..event_loop.retry import ModelRetryStrategy __all__ = [ "Agent", diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index 89c51ec4e..c9350b3fd 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -28,7 +28,7 @@ from .._async import run_async from ..event_loop.event_loop import INITIAL_DELAY, MAX_ATTEMPTS, MAX_DELAY, event_loop_cycle from ..tools._tool_helpers import generate_missing_tool_result_content -from .retry import ModelRetryStrategy +from ..event_loop.retry import ModelRetryStrategy if TYPE_CHECKING: from ..experimental.tools import ToolProvider diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index 7bc5b517d..7d1436b12 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -43,6 +43,7 @@ from ..types.tools import ToolResult, ToolUse from ._recover_message_on_max_tokens_reached import recover_message_on_max_tokens_reached from .streaming import stream_messages +from .retry import ModelRetryStrategy if TYPE_CHECKING: from ..agent import Agent @@ -387,7 +388,6 @@ async def _handle_model_execution( # Emit backwards-compatible events if retry strategy supports it # (prior to making the retry strategy configurable, this is what we emitted) - from ..agent import ModelRetryStrategy if ( isinstance(agent.retry_strategy, ModelRetryStrategy) diff --git a/src/strands/agent/retry.py b/src/strands/event_loop/retry.py similarity index 100% rename from src/strands/agent/retry.py rename to src/strands/event_loop/retry.py diff --git a/tests/strands/agent/hooks/test_agent_events.py b/tests/strands/agent/hooks/test_agent_events.py index 05e39f283..47f476747 100644 --- a/tests/strands/agent/hooks/test_agent_events.py +++ b/tests/strands/agent/hooks/test_agent_events.py @@ -34,7 +34,7 @@ async def streaming_tool(): @pytest.fixture def mock_sleep(): - with patch.object(strands.agent.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: + with patch.object(strands.event_loop.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock diff --git a/tests/strands/agent/test_agent_hooks.py b/tests/strands/agent/test_agent_hooks.py index 9b0e36216..d344021ec 100644 --- a/tests/strands/agent/test_agent_hooks.py +++ b/tests/strands/agent/test_agent_hooks.py @@ -104,7 +104,7 @@ class User(BaseModel): @pytest.fixture def mock_sleep(): - with patch.object(strands.agent.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: + with patch.object(strands.event_loop.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock diff --git a/tests/strands/agent/test_agent_retry.py b/tests/strands/agent/test_agent_retry.py index a900d0f1f..64b938ce9 100644 --- a/tests/strands/agent/test_agent_retry.py +++ b/tests/strands/agent/test_agent_retry.py @@ -4,8 +4,7 @@ import pytest -from strands import Agent -from strands.agent.retry import ModelRetryStrategy +from strands import Agent, ModelRetryStrategy from strands.types.exceptions import ModelThrottledException from tests.fixtures.mocked_model_provider import MockedModelProvider diff --git a/tests/strands/agent/test_retry.py b/tests/strands/agent/test_retry.py index 4ff33a289..830c1b5b8 100644 --- a/tests/strands/agent/test_retry.py +++ b/tests/strands/agent/test_retry.py @@ -4,7 +4,7 @@ import pytest -from strands.agent.retry import ModelRetryStrategy +from strands import ModelRetryStrategy from strands.hooks import AfterInvocationEvent, AfterModelCallEvent, HookRegistry from strands.types._events import EventLoopThrottleEvent from strands.types.exceptions import ModelThrottledException diff --git a/tests/strands/event_loop/test_event_loop.py b/tests/strands/event_loop/test_event_loop.py index 16c21b7f5..d20191510 100644 --- a/tests/strands/event_loop/test_event_loop.py +++ b/tests/strands/event_loop/test_event_loop.py @@ -31,7 +31,7 @@ @pytest.fixture def mock_sleep(): - with patch.object(strands.agent.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: + with patch.object(strands.event_loop.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock @@ -114,7 +114,7 @@ def tool_stream(tool): @pytest.fixture def hook_registry(): - from strands.agent.retry import ModelRetryStrategy + from strands.event_loop.retry import ModelRetryStrategy registry = HookRegistry() # Register default retry strategy @@ -137,7 +137,7 @@ def tool_executor(): @pytest.fixture def agent(model, system_prompt, messages, tool_registry, thread_pool, hook_registry, tool_executor): - from strands.agent.retry import ModelRetryStrategy + from strands.event_loop.retry import ModelRetryStrategy mock = unittest.mock.Mock(name="agent") mock.__class__ = Agent From b122ec2bb0ec65b9dab6d36783bc1c9383554d16 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Thu, 15 Jan 2026 16:14:01 -0500 Subject: [PATCH 10/13] fix: linting error --- src/strands/agent/__init__.py | 2 +- src/strands/agent/agent.py | 2 +- src/strands/event_loop/event_loop.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/strands/agent/__init__.py b/src/strands/agent/__init__.py index 6febef5ce..089bcad2b 100644 --- a/src/strands/agent/__init__.py +++ b/src/strands/agent/__init__.py @@ -7,6 +7,7 @@ - Retry Strategies: Configurable retry behavior for model calls """ +from ..event_loop.retry import ModelRetryStrategy from .agent import Agent from .agent_result import AgentResult from .base import AgentBase @@ -16,7 +17,6 @@ SlidingWindowConversationManager, SummarizingConversationManager, ) -from ..event_loop.retry import ModelRetryStrategy __all__ = [ "Agent", diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index c9350b3fd..ba48a3236 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -27,8 +27,8 @@ from .. import _identifier from .._async import run_async from ..event_loop.event_loop import INITIAL_DELAY, MAX_ATTEMPTS, MAX_DELAY, event_loop_cycle -from ..tools._tool_helpers import generate_missing_tool_result_content from ..event_loop.retry import ModelRetryStrategy +from ..tools._tool_helpers import generate_missing_tool_result_content if TYPE_CHECKING: from ..experimental.tools import ToolProvider diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index 7d1436b12..ff7e0d1e1 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -42,8 +42,8 @@ from ..types.streaming import StopReason from ..types.tools import ToolResult, ToolUse from ._recover_message_on_max_tokens_reached import recover_message_on_max_tokens_reached -from .streaming import stream_messages from .retry import ModelRetryStrategy +from .streaming import stream_messages if TYPE_CHECKING: from ..agent import Agent From c71078cd3a9de5c0535e5c6e5f7c000174184727 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Thu, 15 Jan 2026 17:36:04 -0500 Subject: [PATCH 11/13] Rename retry file to include underscore --- src/strands/__init__.py | 2 +- src/strands/agent/__init__.py | 2 +- src/strands/agent/agent.py | 2 +- src/strands/event_loop/{retry.py => _retry.py} | 0 src/strands/event_loop/event_loop.py | 2 +- tests/strands/agent/hooks/test_agent_events.py | 2 +- tests/strands/agent/test_agent_hooks.py | 2 +- tests/strands/event_loop/test_event_loop.py | 6 +++--- 8 files changed, 9 insertions(+), 9 deletions(-) rename src/strands/event_loop/{retry.py => _retry.py} (100%) diff --git a/src/strands/__init__.py b/src/strands/__init__.py index ffc888a93..6026d4240 100644 --- a/src/strands/__init__.py +++ b/src/strands/__init__.py @@ -3,7 +3,7 @@ from . import agent, models, telemetry, types from .agent.agent import Agent from .agent.base import AgentBase -from .event_loop.retry import ModelRetryStrategy +from .event_loop._retry import ModelRetryStrategy from .tools.decorator import tool from .types.tools import ToolContext diff --git a/src/strands/agent/__init__.py b/src/strands/agent/__init__.py index 089bcad2b..2e40866a9 100644 --- a/src/strands/agent/__init__.py +++ b/src/strands/agent/__init__.py @@ -7,7 +7,7 @@ - Retry Strategies: Configurable retry behavior for model calls """ -from ..event_loop.retry import ModelRetryStrategy +from ..event_loop._retry import ModelRetryStrategy from .agent import Agent from .agent_result import AgentResult from .base import AgentBase diff --git a/src/strands/agent/agent.py b/src/strands/agent/agent.py index ba48a3236..dbab30b64 100644 --- a/src/strands/agent/agent.py +++ b/src/strands/agent/agent.py @@ -26,8 +26,8 @@ from .. import _identifier from .._async import run_async +from ..event_loop._retry import ModelRetryStrategy from ..event_loop.event_loop import INITIAL_DELAY, MAX_ATTEMPTS, MAX_DELAY, event_loop_cycle -from ..event_loop.retry import ModelRetryStrategy from ..tools._tool_helpers import generate_missing_tool_result_content if TYPE_CHECKING: diff --git a/src/strands/event_loop/retry.py b/src/strands/event_loop/_retry.py similarity index 100% rename from src/strands/event_loop/retry.py rename to src/strands/event_loop/_retry.py diff --git a/src/strands/event_loop/event_loop.py b/src/strands/event_loop/event_loop.py index ff7e0d1e1..5f1ad5b60 100644 --- a/src/strands/event_loop/event_loop.py +++ b/src/strands/event_loop/event_loop.py @@ -42,7 +42,7 @@ from ..types.streaming import StopReason from ..types.tools import ToolResult, ToolUse from ._recover_message_on_max_tokens_reached import recover_message_on_max_tokens_reached -from .retry import ModelRetryStrategy +from ._retry import ModelRetryStrategy from .streaming import stream_messages if TYPE_CHECKING: diff --git a/tests/strands/agent/hooks/test_agent_events.py b/tests/strands/agent/hooks/test_agent_events.py index 47f476747..f511c7019 100644 --- a/tests/strands/agent/hooks/test_agent_events.py +++ b/tests/strands/agent/hooks/test_agent_events.py @@ -34,7 +34,7 @@ async def streaming_tool(): @pytest.fixture def mock_sleep(): - with patch.object(strands.event_loop.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: + with patch.object(strands.event_loop._retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock diff --git a/tests/strands/agent/test_agent_hooks.py b/tests/strands/agent/test_agent_hooks.py index d344021ec..3946f5cd3 100644 --- a/tests/strands/agent/test_agent_hooks.py +++ b/tests/strands/agent/test_agent_hooks.py @@ -104,7 +104,7 @@ class User(BaseModel): @pytest.fixture def mock_sleep(): - with patch.object(strands.event_loop.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: + with patch.object(strands.event_loop._retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock diff --git a/tests/strands/event_loop/test_event_loop.py b/tests/strands/event_loop/test_event_loop.py index d20191510..5f3372a69 100644 --- a/tests/strands/event_loop/test_event_loop.py +++ b/tests/strands/event_loop/test_event_loop.py @@ -31,7 +31,7 @@ @pytest.fixture def mock_sleep(): - with patch.object(strands.event_loop.retry.asyncio, "sleep", new_callable=AsyncMock) as mock: + with patch.object(strands.event_loop._retry.asyncio, "sleep", new_callable=AsyncMock) as mock: yield mock @@ -114,7 +114,7 @@ def tool_stream(tool): @pytest.fixture def hook_registry(): - from strands.event_loop.retry import ModelRetryStrategy + from strands.event_loop._retry import ModelRetryStrategy registry = HookRegistry() # Register default retry strategy @@ -137,7 +137,7 @@ def tool_executor(): @pytest.fixture def agent(model, system_prompt, messages, tool_registry, thread_pool, hook_registry, tool_executor): - from strands.event_loop.retry import ModelRetryStrategy + from strands.event_loop._retry import ModelRetryStrategy mock = unittest.mock.Mock(name="agent") mock.__class__ = Agent From 083be7e4c67328f255101e0260ec02fb77656690 Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Fri, 16 Jan 2026 15:45:18 -0500 Subject: [PATCH 12/13] fix: local imports --- tests/strands/agent/test_agent_retry.py | 6 ++---- tests/strands/event_loop/test_event_loop.py | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/strands/agent/test_agent_retry.py b/tests/strands/agent/test_agent_retry.py index 64b938ce9..17f5a976b 100644 --- a/tests/strands/agent/test_agent_retry.py +++ b/tests/strands/agent/test_agent_retry.py @@ -5,6 +5,8 @@ import pytest from strands import Agent, ModelRetryStrategy +from strands.event_loop.event_loop import INITIAL_DELAY, MAX_ATTEMPTS, MAX_DELAY +from strands.hooks import AfterModelCallEvent from strands.types.exceptions import ModelThrottledException from tests.fixtures.mocked_model_provider import MockedModelProvider @@ -59,8 +61,6 @@ class CustomRetryStrategy(ModelRetryStrategy): def test_agent_default_retry_strategy_uses_event_loop_constants(): """Test that default retry strategy uses constants from event_loop module.""" - from strands.event_loop.event_loop import INITIAL_DELAY, MAX_ATTEMPTS, MAX_DELAY - agent = Agent() assert agent.retry_strategy._max_attempts == MAX_ATTEMPTS @@ -74,8 +74,6 @@ def test_retry_strategy_registered_as_hook(): agent = Agent(retry_strategy=custom_strategy) # Verify retry strategy callback is registered - from strands.hooks import AfterModelCallEvent - callbacks = list(agent.hooks.get_callbacks_for(AfterModelCallEvent(agent=agent, exception=None))) # Should have at least one callback (from retry strategy) diff --git a/tests/strands/event_loop/test_event_loop.py b/tests/strands/event_loop/test_event_loop.py index 5f3372a69..b5df9c42c 100644 --- a/tests/strands/event_loop/test_event_loop.py +++ b/tests/strands/event_loop/test_event_loop.py @@ -1,3 +1,4 @@ +import asyncio import concurrent import unittest.mock from unittest.mock import ANY, AsyncMock, MagicMock, call, patch @@ -7,6 +8,7 @@ import strands import strands.telemetry from strands import Agent +from strands.event_loop._retry import ModelRetryStrategy from strands.hooks import ( AfterModelCallEvent, BeforeModelCallEvent, @@ -114,8 +116,6 @@ def tool_stream(tool): @pytest.fixture def hook_registry(): - from strands.event_loop._retry import ModelRetryStrategy - registry = HookRegistry() # Register default retry strategy retry_strategy = ModelRetryStrategy() @@ -137,8 +137,6 @@ def tool_executor(): @pytest.fixture def agent(model, system_prompt, messages, tool_registry, thread_pool, hook_registry, tool_executor): - from strands.event_loop._retry import ModelRetryStrategy - mock = unittest.mock.Mock(name="agent") mock.__class__ = Agent mock.config.cache_points = [] @@ -700,7 +698,6 @@ async def test_event_loop_tracing_with_throttling_exception( ] # Mock the time.sleep function to speed up the test - import asyncio with patch.object(asyncio, "sleep", new_callable=unittest.mock.AsyncMock): stream = strands.event_loop.event_loop.event_loop_cycle( From 71c5109cfad678a6f18e699a1301bbb3d7b212be Mon Sep 17 00:00:00 2001 From: Mackenzie Zastrow Date: Fri, 16 Jan 2026 16:27:57 -0500 Subject: [PATCH 13/13] Remove one line --- tests/strands/event_loop/test_event_loop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/strands/event_loop/test_event_loop.py b/tests/strands/event_loop/test_event_loop.py index b5df9c42c..d4afd579b 100644 --- a/tests/strands/event_loop/test_event_loop.py +++ b/tests/strands/event_loop/test_event_loop.py @@ -698,7 +698,6 @@ async def test_event_loop_tracing_with_throttling_exception( ] # Mock the time.sleep function to speed up the test - with patch.object(asyncio, "sleep", new_callable=unittest.mock.AsyncMock): stream = strands.event_loop.event_loop.event_loop_cycle( agent=agent,