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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ to include examples, links to docs, or any other relevant information.

### Added

- **Experimental**: `temporalio.contrib.strands` now supports durable,
Workflow-isolated Strands sandboxes through `TemporalSandbox` and
worker-side factories with run and Workflow-chain context registered with
`StrandsPlugin(sandboxes=...)`, using one shared activity set with optional
live Workflow Streams output.
### Changed

### Deprecated
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
148 changes: 148 additions & 0 deletions temporalio/contrib/strands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,154 @@ 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 (
SandboxWorkflowContext,
StrandsPlugin,
TemporalAgent,
TemporalSandbox,
)

async def build_sandbox(context: SandboxWorkflowContext) -> DockerSandbox:
# Application-specific and idempotent: return the existing container when
# another activity worker has already provisioned this Workflow's sandbox.
container = await get_or_create_build_container(
context.chain.first_execution_run_id
)
return DockerSandbox(container.name)

# workflow
agent = TemporalAgent(
sandbox=TemporalSandbox(
"build",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we are using SandboxWorkflowContext do we need the name?

Suggested change
"build",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name selects the corresponding sandbox defined in StrandsPlugin(sandboxes=...), whereas the context contains the Workflow ID.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the factory function handle all sandbox retrievals instead of name map?

TemporalAgent(
    sandbox=TemporalSandbox(start_to_close_timeout=timedelta(minutes=5))
)
...
plugins=[StrandsPlugin(sandboxes=build_sandbox)]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name map is good because it's what we already do for MCP, and it allows us to have multiple sandboxes in the same environment.

start_to_close_timeout=timedelta(minutes=5),
),
)

# worker
Worker(
...,
plugins=[StrandsPlugin(sandboxes={
"build": build_sandbox,
})],
)
```

The plugin registers one shared set of sandbox activities regardless of how
many factories are configured. Each operation carries the selected sandbox
name in its activity input so the worker can dispatch it to the matching
factory.

The factory is called lazily with a `SandboxWorkflowContext` containing the
current `run_id` and a `chain` identity with the Workflow's namespace, Workflow
ID, and first execution Run ID. The worker-local cache uses the sandbox name and
chain identity, so each Workflow chain gets a separate sandbox for each
registered name. Retries, Continue-As-New, Reset, and Cron runs belong to the
same chain and therefore use the same sandbox; unrelated Workflow chains do not
share one. Multiple `TemporalSandbox` objects with the same name in one chain
intentionally share that chain's sandbox.

The factory receives the current Run ID only when a worker-local cache entry is
created. A later run in the same chain reuses a warm entry without calling the
factory again. After eviction, the next factory call receives the Run ID of the
run that recreates the entry.

Factories may be synchronous or asynchronous. Synchronous factories must only
construct a lightweight adapter and must not block the activity event loop;
use an asynchronous factory for remote lookup or provisioning. A factory may
run more than once for the same context after cache eviction or on different
workers, so provisioning must be idempotent. Strands' `DockerSandbox` only
connects to an already-running container; it does not create one.

Worker-local adapters are reused until they have been idle for five minutes.
Set `sandbox_cache_idle_timeout` on `StrandsPlugin` to change that duration.
Eviction only drops the local adapter. Provisioning, teardown, and cleanup of
orphaned backing environments remain the application's responsibility; use a
backend TTL or reaper for workflows that are terminated before normal cleanup.

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. The context factory must therefore reconnect every
worker to the same Workflow-scoped backing environment rather than relying on
per-process state. A single worker on the queue also satisfies this.

Reset does not roll back commands or filesystem mutations already performed in
the external sandbox, just as it does not roll back other Activity side effects.
Account for that when resetting a Workflow that uses a sandbox.

`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
5 changes: 5 additions & 0 deletions temporalio/contrib/strands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

from . import workflow
from ._plugin import StrandsPlugin
from ._sandbox_activity import SandboxWorkflowChain, SandboxWorkflowContext
from ._temporal_agent import TemporalAgent
from ._temporal_mcp_client import TemporalMCPClient
from ._temporal_sandbox import TemporalSandbox

__all__ = [
"StrandsPlugin",
"SandboxWorkflowChain",
"SandboxWorkflowContext",
"TemporalAgent",
"TemporalMCPClient",
"TemporalSandbox",
"workflow",
]
35 changes: 33 additions & 2 deletions temporalio/contrib/strands/_plugin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import replace
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,10 @@

from ._failure_converter import StrandsFailureConverter
from ._model_activity import ModelActivity
from ._sandbox_activity import (
SandboxActivities,
SandboxWorkflowContext,
)
from ._temporal_mcp_client import (
_evict_connection,
build_call_tool_activity,
Expand All @@ -39,16 +44,32 @@ 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 one stable set of activities that
dispatches each operation by sandbox name. Each factory receives the
requesting Workflow run's context and may return a sandbox directly or
awaitably. Worker-local adapters are cached by sandbox name and Workflow
chain until ``sandbox_cache_idle_timeout`` elapses. 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[
[SandboxWorkflowContext],
Sandbox | Awaitable[Sandbox],
],
]
| None = None,
mcp_connection_idle_timeout: timedelta | None = None,
sandbox_cache_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 +83,14 @@ def __init__(
ma = ModelActivity(models, default_name=default_name)
activities.extend([ma.invoke_model, ma.invoke_model_streaming])

sandbox_activities = (
SandboxActivities(sandboxes, sandbox_cache_idle_timeout)
if sandboxes
else None
)
if sandbox_activities is not None:
activities.extend(sandbox_activities.activities())

mcp_clients = mcp_clients or {}
for server, client_factory in mcp_clients.items():
activities.append(
Expand All @@ -80,6 +109,8 @@ async def run_context() -> AsyncGenerator[None, None]:
try:
yield
finally:
if sandbox_activities is not None:
await sandbox_activities.aclose()
Comment on lines +112 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not clear shared sandbox state when one worker exits

When one client-level StrandsPlugin instance supplies multiple Workers, every Worker uses the same sandbox_activities object while run_context is entered separately for each Worker. Shutting down any one of them therefore calls aclose() on the shared cache, clearing records used by the remaining Workers and cancelling any factory task they are currently awaiting; an unrelated activity can consequently be cancelled or forced onto a second adapter merely because another Worker stopped. The cleanup needs per-Worker ownership or reference counting so it only closes this shared state after its last Worker exits.

Useful? React with 👍 / 👎.

for server in mcp_clients:
await _evict_connection(server)

Expand Down
Loading
Loading