diff --git a/CHANGELOG.md b/CHANGELOG.md index be3444177..96a5f6029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ to include examples, links to docs, or any other relevant information. ### Fixed +- `StrandsPlugin` now disables Botocore retries for its default Bedrock model so + model request retries are handled exclusively by Temporal. + ### Security ## [1.32.0] - 2026-08-24 diff --git a/temporalio/contrib/strands/README.md b/temporalio/contrib/strands/README.md index 126f4bd95..5381f29f1 100644 --- a/temporalio/contrib/strands/README.md +++ b/temporalio/contrib/strands/README.md @@ -79,9 +79,10 @@ Note: Use `agent.invoke_async(message)` instead of `agent(message)`. The synchro ## Models -`StrandsPlugin(models=...)` takes a mapping of `name → factory`. Each factory is called lazily on first use (on the worker, outside the workflow sandbox) and the constructed model is cached for the worker's lifetime. `TemporalAgent(model="name", ...)` selects which factory to invoke and carries the activity options for that agent's model calls. If `models` is omitted, the plugin registers a single `BedrockModel()` factory under the name `"bedrock"`, matching Strands' own implicit default. +`StrandsPlugin(models=...)` takes a mapping of `name → factory`. Each factory is called lazily on first use (on the worker, outside the workflow sandbox) and the constructed model is cached for the worker's lifetime. `TemporalAgent(model="name", ...)` selects which factory to invoke and carries the activity options for that agent's model calls. If `models` is omitted, the plugin registers a single `BedrockModel` factory under the name `"bedrock"` with Botocore retries disabled so Temporal owns retries. ```python +from botocore.config import Config as BotocoreConfig from strands.models.anthropic import AnthropicModel from strands.models.bedrock import BedrockModel @@ -101,7 +102,9 @@ class MultiModelWorkflow: # worker Worker(..., plugins=[StrandsPlugin(models={ "claude": lambda: AnthropicModel(client_args={"api_key": "..."}), - "bedrock": lambda: BedrockModel(), + "bedrock": lambda: BedrockModel( + boto_client_config=BotocoreConfig(retries={"max_attempts": 0}) + ), })]) ``` @@ -109,7 +112,9 @@ Each `TemporalAgent` carries its own activity options (timeouts, retry policy, t ## Retries -`TemporalAgent` disables Strands' built-in `ModelRetryStrategy` so retries are handled exclusively by Temporal. Configure retries via `retry_policy` on `TemporalAgent`, and on the activity options accepted by `workflow.activity_as_tool`, `workflow.activity_as_hook`, and `TemporalMCPClient`: +`TemporalAgent` disables Strands' built-in `ModelRetryStrategy` so retries are handled exclusively by Temporal. The plugin's default Bedrock model also disables Botocore retries. When supplying your own model factory, disable that provider client's retries as shown in the Bedrock example above. + +Configure retries via `retry_policy` on `TemporalAgent`, and on the activity options accepted by `workflow.activity_as_tool`, `workflow.activity_as_hook`, and `TemporalMCPClient`: ```python from temporalio.common import RetryPolicy diff --git a/temporalio/contrib/strands/_plugin.py b/temporalio/contrib/strands/_plugin.py index 0f1972666..9d70e10fa 100644 --- a/temporalio/contrib/strands/_plugin.py +++ b/temporalio/contrib/strands/_plugin.py @@ -3,7 +3,9 @@ from dataclasses import replace from datetime import timedelta +from botocore.config import Config as BotocoreConfig from strands.models import BedrockModel, Model +from strands.models.bedrock import DEFAULT_READ_TIMEOUT from strands.tools.mcp import MCPClient from temporalio.contrib.pydantic import pydantic_data_converter @@ -21,6 +23,16 @@ ) +def _default_bedrock_model() -> Model: + # Temporal owns retries so Botocore must not retry within an activity attempt. + return BedrockModel( + boto_client_config=BotocoreConfig( + read_timeout=DEFAULT_READ_TIMEOUT, + retries={"max_attempts": 0}, + ) + ) + + class StrandsPlugin(SimplePlugin): """Temporal Worker plugin for the Strands Agents SDK. @@ -50,12 +62,12 @@ def __init__( ) -> None: """Build the plugin from optional model and MCP transport factories. - If ``models`` is omitted, registers a single ``BedrockModel()`` factory - under the name ``"bedrock"``, matching Strands' own implicit default. + If ``models`` is omitted, registers a single ``BedrockModel`` factory + under the name ``"bedrock"`` with Botocore retries disabled. """ default_name: str | None = None if models is None: - models = {"bedrock": lambda: BedrockModel()} + models = {"bedrock": _default_bedrock_model} default_name = "bedrock" activities: list[Callable] = [] if models: diff --git a/tests/contrib/strands/test_plugin.py b/tests/contrib/strands/test_plugin.py new file mode 100644 index 000000000..a9d752d0c --- /dev/null +++ b/tests/contrib/strands/test_plugin.py @@ -0,0 +1,37 @@ +import typing + +import pytest +from botocore.config import Config as BotocoreConfig +from strands.models import Model +from strands.models.bedrock import DEFAULT_READ_TIMEOUT + +import temporalio.contrib.strands._plugin as plugin_module +from temporalio.contrib.strands import StrandsPlugin +from temporalio.contrib.strands._model_activity import ModelActivity +from tests.contrib.strands.mock_model import MockModel + + +def test_default_bedrock_model_disables_botocore_retries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_configs: list[BotocoreConfig] = [] + model = MockModel([]) + + def bedrock_model(*, boto_client_config: BotocoreConfig) -> Model: + captured_configs.append(boto_client_config) + return model + + monkeypatch.setattr(plugin_module, "BedrockModel", bedrock_model) + + plugin = StrandsPlugin() + activities = plugin.activities + assert isinstance(activities, list) + model_activity = typing.cast( + ModelActivity, + typing.cast(typing.Any, activities[0]).__self__, + ) + + assert model_activity._get_model(None) is model + assert len(captured_configs) == 1 + assert getattr(captured_configs[0], "read_timeout") == DEFAULT_READ_TIMEOUT + assert getattr(captured_configs[0], "retries") == {"max_attempts": 0}