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 @@ -20,6 +20,9 @@ to include examples, links to docs, or any other relevant information.

### Added

- **Experimental**: `temporalio.contrib.strands` now supports durable Strands
sandboxes through `TemporalSandbox` and worker-side factories registered with
`StrandsPlugin(sandboxes=...)`, with optional live Workflow Streams output.
- Added `temporalio.converter.create_payload_validation_error` to create the
non-retryable application error used when a converted payload fails validation.
- Added experimental `temporalio.contrib.opentelemetry.ReplaySafeMeterProvider` and
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ lambda-worker-otel = [
]
aioboto3 = ["aioboto3>=10.4.0", "types-aioboto3[s3]>=10.4.0"]
google-genai = ["google-genai>=2.10.0,<3.0.0"]
strands-agents = ["strands-agents>=1.39.0"]
strands-agents = ["strands-agents>=1.47.0"]

[project.urls]
Homepage = "https://github.com/temporalio/sdk-python"
Expand Down Expand Up @@ -97,7 +97,7 @@ dev = [
"opentelemetry-sdk-extension-aws>=2.0.0,<3",
"pytest-flakefinder>=1.1.0",
"async-timeout>=4.0,<6; python_version < '3.11'",
"strands-agents>=1.39.0",
"strands-agents>=1.47.0",
"strands-agents-tools>=0.5.2",
"mcp>=1.9.4,<2",
]
Expand Down
104 changes: 104 additions & 0 deletions temporalio/contrib/strands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,110 @@ async for item in WorkflowStreamClient.create(client, workflow_id).subscribe(
print(item.data)
```

## Sandboxes

`TemporalSandbox` implements Strands' sandbox API by scheduling every command,
code, and filesystem operation as a Temporal Activity. Register the real
worker-side sandbox under a name, then select that name in workflow code:

```python
from strands.sandbox.docker import DockerSandbox
from temporalio.contrib.strands import StrandsPlugin, TemporalAgent, TemporalSandbox

# workflow
agent = TemporalAgent(
sandbox=TemporalSandbox(
"build",
start_to_close_timeout=timedelta(minutes=5),
),
)

# worker
Worker(
...,
plugins=[StrandsPlugin(sandboxes={
"build": lambda: DockerSandbox("agent-build-container"),
})],
)
```

The factory is called lazily on first use. Its sandbox instance is cached and
shared by all activities for that name for the worker's lifetime, so tools see
the same filesystem and working state. Provisioning and teardown of the backing
environment remain the application's responsibility.

That cache is per worker *process*, while successive sandbox activities from one
workflow are routed independently across the task queue. With more than one
worker on the queue, a `write-file` can land on one worker and the following
`read-file` on another, so the factory must point at state the whole queue
shares — a named Docker container, an SSH host — rather than a per-process
temporary directory. A single worker on the queue also satisfies this.

`SandboxTimeoutError` and any `FileNotFoundError` — including its
`SandboxPathNotFoundError` subclass — cross the activity boundary as
non-retryable failures and are re-raised inside the workflow with the sandbox's
own message, so a command that exceeds its `timeout` or a path that does not
exist surfaces to the agent on the first attempt instead of retrying. Other
sandbox failures, including the `OSError` that Strands documents for a failed
`write_file`, are retried under the `retry_policy` you pass to
`TemporalSandbox`.

Like all Temporal Activities, sandbox operations have at-least-once execution
semantics. A worker can finish a command or filesystem mutation and fail before
recording its result, causing a retry to perform the operation again. Use a
bounded `retry_policy`, and make commands and mutations idempotent when repeated
execution would be unsafe.

By default, `TemporalSandbox.get_tools()` vends `sandbox_bash` and
`sandbox_file_editor`. A tool passed explicitly through `TemporalAgent(tools=...)`
with either name takes precedence, following Strands' normal sandbox-tool
override behavior.

Execution output is always buffered into the activity result so workflow replay
observes the same ordered `StreamChunk` and `ExecutionResult` values. For live,
observer-facing output, set `streaming_topic` and host a `WorkflowStream` on the
workflow. The activity publishes each `StreamChunk` as it arrives; the final
`ExecutionResult` is returned only through the buffered activity result:

```python
from datetime import timedelta

from strands.sandbox import StreamChunk
from temporalio.contrib.strands import TemporalSandbox
from temporalio.contrib.workflow_streams import WorkflowStream, WorkflowStreamClient

# workflow __init__
self.stream = WorkflowStream()
self.sandbox = TemporalSandbox(
"build",
start_to_close_timeout=timedelta(minutes=5),
streaming_topic="sandbox-events",
)

# external client
async for item in WorkflowStreamClient.create(client, workflow_id).subscribe(
["sandbox-events"], result_type=StreamChunk
):
print(item.data.stream_type, item.data.data)
```

The topic is an observer-facing merged log. If sandbox executions overlap,
their chunks may interleave. Use different `streaming_topic` values when the
consumer needs separate logs; workflow code still receives the correctly
separated, complete buffered result for each call. Because publications are
observer-facing side effects of an activity attempt, a failed attempt that
Temporal retries may leave chunks in the topic before the retry publishes its
own output.

Streaming is disabled by default. When `streaming_topic=None`, sandbox
activities do not construct a `WorkflowStreamClient` and the workflow does not
need to host a `WorkflowStream`.

All arguments and results cross Temporal's payload boundary and enter workflow
history. Keep command output and files within the server's configured payload
size limits; use external storage for large artifacts. In particular, `env`
values are recorded in history and must not contain secrets.

## Tools

Decorate non-deterministic tools with `@activity.defn`, or if you're importing tools from `strands_tools`, wrap them in a thin async function. Then, register the activity on the worker via `Worker(activities=[...])` and pass it to the agent with `workflow.activity_as_tool(activity, **options)` along with any activity options (e.g. `start_to_close_timeout`):
Expand Down
2 changes: 2 additions & 0 deletions temporalio/contrib/strands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
from ._plugin import StrandsPlugin
from ._temporal_agent import TemporalAgent
from ._temporal_mcp_client import TemporalMCPClient
from ._temporal_sandbox import TemporalSandbox

__all__ = [
"StrandsPlugin",
"TemporalAgent",
"TemporalMCPClient",
"TemporalSandbox",
"workflow",
]
13 changes: 12 additions & 1 deletion temporalio/contrib/strands/_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from datetime import timedelta

from strands.models import BedrockModel, Model
from strands.sandbox import Sandbox
from strands.tools.mcp import MCPClient

from temporalio.contrib.pydantic import pydantic_data_converter
Expand All @@ -14,6 +15,7 @@

from ._failure_converter import StrandsFailureConverter
from ._model_activity import ModelActivity
from ._sandbox_activity import SandboxActivities
from ._temporal_mcp_client import (
_evict_connection,
build_call_tool_activity,
Expand All @@ -39,16 +41,22 @@ class StrandsPlugin(SimplePlugin):
``mcp_connection_idle_timeout`` controls how long a worker-process MCP
connection is kept open between ``call-tool`` activities before it is
disconnected; the timer resets on every reuse. Defaults to 5 minutes.

When ``sandboxes`` is supplied, registers a stable set of name-prefixed
activities for every sandbox factory. Each factory is called lazily and
its sandbox is shared by those activities for the worker's lifetime. Use
the same name in workflow-side ``TemporalSandbox(name)`` instances.
"""

def __init__(
self,
*,
models: dict[str, Callable[[], Model]] | None = None,
mcp_clients: dict[str, Callable[[], MCPClient]] | None = None,
sandboxes: dict[str, Callable[[], Sandbox]] | None = None,
mcp_connection_idle_timeout: timedelta | None = None,
) -> None:
"""Build the plugin from optional model and MCP transport factories.
"""Build the plugin from optional model, MCP, and sandbox factories.

If ``models`` is omitted, registers a single ``BedrockModel()`` factory
under the name ``"bedrock"``, matching Strands' own implicit default.
Expand All @@ -62,6 +70,9 @@ def __init__(
ma = ModelActivity(models, default_name=default_name)
activities.extend([ma.invoke_model, ma.invoke_model_streaming])

for name, sandbox_factory in (sandboxes or {}).items():
activities.extend(SandboxActivities(name, sandbox_factory).activities())

mcp_clients = mcp_clients or {}
for server, client_factory in mcp_clients.items():
activities.append(
Expand Down
Loading
Loading