Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions temporalio/contrib/strands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -101,15 +102,19 @@ 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})
),
})])
```

Each `TemporalAgent` carries its own activity options (timeouts, retry policy, task queue, streaming topic) and dispatches to the shared model activity, which resolves the model name against the registered factories at runtime. A name not present in `models` raises `ValueError` inside the activity.

## 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
Expand Down
18 changes: 15 additions & 3 deletions temporalio/contrib/strands/_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions tests/contrib/strands/test_plugin.py
Original file line number Diff line number Diff line change
@@ -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}
Loading