diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c718b40c0..6c4d36b99 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,13 +6,16 @@ # Below are owners for modules in the temporalio/contrib/ -# and tests/contrib/ directories that are owned by teams -# other than the SDK team. For each one, we add the owning team, +# and tests/contrib/ directories that are owned by teams +# other than the SDK team. For each one, we add the owning team, # as well as @temporalio/sdk, so the SDK team can continue to # manage repo-wide concerns. +/temporalio/contrib/common/ @temporalio/ai-sdk @temporalio/sdk /temporalio/contrib/google_adk_agents/ @temporalio/ai-sdk @temporalio/sdk /temporalio/contrib/langsmith/ @temporalio/ai-sdk @temporalio/sdk /temporalio/contrib/openai_agents/ @temporalio/ai-sdk @temporalio/sdk +/temporalio/contrib/strands/ @temporalio/ai-sdk @temporalio/sdk /tests/contrib/google_adk_agents/ @temporalio/ai-sdk @temporalio/sdk /tests/contrib/langsmith/ @temporalio/ai-sdk @temporalio/sdk /tests/contrib/openai_agents/ @temporalio/ai-sdk @temporalio/sdk +/tests/contrib/strands/ @temporalio/ai-sdk @temporalio/sdk diff --git a/pyproject.toml b/pyproject.toml index da9c8bdd3..e6f8fa9b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ lambda-worker-otel = [ "opentelemetry-sdk-extension-aws>=2.0.0,<3", ] aioboto3 = ["aioboto3>=10.4.0", "types-aioboto3[s3]>=10.4.0"] +strands-agents = ["strands-agents>=1.39.0"] [project.urls] Homepage = "https://github.com/temporalio/sdk-python" @@ -85,6 +86,8 @@ 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-tools>=0.5.2", ] [tool.poe.tasks] diff --git a/temporalio/contrib/openai_agents/_heartbeat_decorator.py b/temporalio/contrib/openai_agents/_heartbeat_decorator.py index 4baff6706..7c5b9193d 100644 --- a/temporalio/contrib/openai_agents/_heartbeat_decorator.py +++ b/temporalio/contrib/openai_agents/_heartbeat_decorator.py @@ -8,23 +8,22 @@ F = TypeVar("F", bound=Callable[..., Awaitable[Any]]) -def _auto_heartbeater(fn: F) -> F: # type:ignore[reportUnusedClass] - # Propagate type hints from the original callable. +def auto_heartbeater(fn: F) -> F: + """Decorator that heartbeats at half the activity's heartbeat timeout.""" + @wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> Any: heartbeat_timeout = activity.info().heartbeat_timeout heartbeat_task = None if heartbeat_timeout: - # Heartbeat twice as often as the timeout heartbeat_task = asyncio.create_task( - heartbeat_every(heartbeat_timeout.total_seconds() / 2) + _heartbeat_every(heartbeat_timeout.total_seconds() / 2) ) try: return await fn(*args, **kwargs) finally: if heartbeat_task: heartbeat_task.cancel() - # Wait for heartbeat cancellation to complete try: await heartbeat_task except asyncio.CancelledError: @@ -33,8 +32,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: return cast(F, wrapper) -async def heartbeat_every(delay: float, *details: Any) -> None: - """Heartbeat every so often while not cancelled""" +async def _heartbeat_every(delay: float) -> None: while True: await asyncio.sleep(delay) - activity.heartbeat(*details) + activity.heartbeat() diff --git a/temporalio/contrib/openai_agents/_invoke_model_activity.py b/temporalio/contrib/openai_agents/_invoke_model_activity.py index 1aa836eee..a43f9aeaf 100644 --- a/temporalio/contrib/openai_agents/_invoke_model_activity.py +++ b/temporalio/contrib/openai_agents/_invoke_model_activity.py @@ -43,7 +43,7 @@ from typing_extensions import Required, TypedDict from temporalio import activity -from temporalio.contrib.openai_agents._heartbeat_decorator import _auto_heartbeater +from temporalio.contrib.openai_agents._heartbeat_decorator import auto_heartbeater from temporalio.contrib.workflow_streams import WorkflowStreamClient from temporalio.exceptions import ApplicationError @@ -314,7 +314,7 @@ def __init__(self, model_provider: ModelProvider | None = None): ) @activity.defn - @_auto_heartbeater + @auto_heartbeater async def invoke_model_activity(self, input: ActivityModelInput) -> ModelResponse: """Activity that invokes a model with the given input.""" model = self._model_provider.get_model(input.get("model_name")) @@ -337,7 +337,7 @@ async def invoke_model_activity(self, input: ActivityModelInput) -> ModelRespons _raise_for_openai_status(e) @activity.defn - @_auto_heartbeater + @auto_heartbeater async def invoke_model_activity_streaming( self, input: StreamingActivityModelInput ) -> list[TResponseStreamEvent]: @@ -357,7 +357,7 @@ async def invoke_model_activity_streaming( ``streaming_topic`` so external consumers (UIs, tracing, etc.) can observe events as they arrive. - Heartbeats run on a background task via ``_auto_heartbeater`` so + Heartbeats run on a background task via ``auto_heartbeater`` so long initial-token latency or long pauses between chunks do not trip ``heartbeat_timeout``. """ diff --git a/temporalio/contrib/strands/README.md b/temporalio/contrib/strands/README.md new file mode 100644 index 000000000..0758b93b5 --- /dev/null +++ b/temporalio/contrib/strands/README.md @@ -0,0 +1,437 @@ +# Strands Agents + +⚠️ **This package is currently at an experimental release stage.** ⚠️ + +This Temporal [Plugin](https://docs.temporal.io/develop/plugins-guide) allows you to run [Strands Agents](https://strandsagents.com/) inside Temporal Workflows, routing model invocations, tool calls, and MCP tool calls through Temporal Activities for durable execution, Temporal-managed retries, and timeouts. + +## Installation + +```sh +uv add temporalio[strands-agents] +``` + +## Quickstart + +`workflow.py` defines the workflow and runs the worker: + +```python +import asyncio +from datetime import timedelta + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.worker import Worker + + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent(start_to_close_timeout=timedelta(seconds=60)) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) + + +async def main() -> None: + client = await Client.connect("localhost:7233") + worker = Worker( + client, + task_queue="strands", + workflows=[MyWorkflow], + plugins=[StrandsPlugin()], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +`client.py` starts the workflow: + +```python +import asyncio + +from temporalio.client import Client + +from workflow import MyWorkflow + + +async def main() -> None: + client = await Client.connect("localhost:7233") + result = await client.execute_workflow( + MyWorkflow.run, + "Hello", + id="strands-quickstart", + task_queue="strands", + ) + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Note: Use `agent.invoke_async(message)` instead of `agent(message)`. The synchronous form spawns a worker thread, which the workflow sandbox blocks. + +## 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. + +```python +from strands.models.anthropic import AnthropicModel +from strands.models.bedrock import BedrockModel + +# workflow +@workflow.defn +class MultiModelWorkflow: + def __init__(self) -> None: + self.agent_a = TemporalAgent( + model="claude", + start_to_close_timeout=timedelta(seconds=60), + ) + self.agent_b = TemporalAgent( + model="bedrock", + start_to_close_timeout=timedelta(seconds=60), + ) + +# worker +Worker(..., plugins=[StrandsPlugin(models={ + "claude": lambda: AnthropicModel(client_args={"api_key": "..."}), + "bedrock": lambda: BedrockModel(), +})]) +``` + +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`: + +```python +from temporalio.common import RetryPolicy + +TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + retry_policy=RetryPolicy(maximum_attempts=3), +) +``` + +Passing `retry_strategy=...` to `TemporalAgent(...)` raises `ValueError`; remove the argument (or pass `retry_strategy=None`) and put the retry config on the activity options instead. + +## Snapshots + +`TemporalAgent.take_snapshot()` and `TemporalAgent.load_snapshot()` raise `NotImplementedError`. Temporal's event history already persists workflow state durably at a finer granularity than Strands snapshots, so calling either inside a workflow is redundant. + +## Structured Output + +Like Strands `Agent`, `TemporalAgent` supports structured output with `structured_output_model`. The plugin defaults to [`pydantic_data_converter`](../pydantic), so Pydantic types easily serialize across the activity and workflow boundary. + +```python +from pydantic import BaseModel + +class PersonInfo(BaseModel): + name: str + age: int + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + structured_output_model=PersonInfo, + ) + + @workflow.run + async def run(self, prompt: str) -> PersonInfo: + result = await self.agent.invoke_async(prompt) + return result.structured_output +``` + +## Streaming + +To forward model chunks to external consumers, pass `streaming_topic="..."` to `TemporalAgent` and host a `WorkflowStream` on the workflow. Each `StreamEvent` is published on the named topic from inside the model activity; subscribers read via `WorkflowStreamClient`. Chunks are batched on `streaming_batch_interval` (default 100ms). + +```python +# workflow +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.stream = WorkflowStream() + self.agent = TemporalAgent(streaming_topic="events") + +# client +async for item in WorkflowStreamClient.create(client, workflow_id).subscribe( + ["events"], result_type=StreamEvent, +): + print(item.data) +``` + +## 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`): + +```python +from strands_tools import shell +from temporalio.contrib.strands import workflow as strands_workflow + +@activity.defn +async def fetch_user(user_id: str) -> dict: + ... + +@activity.defn(name="shell") +async def shell_activity(command: str) -> dict: + return shell.shell(command=command, non_interactive=True) + +# workflow +agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[ + strands_workflow.activity_as_tool(fetch_user, start_to_close_timeout=timedelta(seconds=30)), + strands_workflow.activity_as_tool(shell_activity, start_to_close_timeout=timedelta(seconds=15)), + ], +) + +# worker +Worker( + ..., + activities=[fetch_user, shell_activity], + plugins=[StrandsPlugin(models=MODELS)], +) +``` + +## Hooks + +Strands' [hook system](https://strandsagents.com/) (`strands.hooks`) lets you subscribe callbacks to events in the agent lifecycle — invocation start/end, model call before/after, tool call before/after, message added. Pass `hooks=[MyHookProvider()]` to `TemporalAgent`: every single-agent hook event fires in workflow context, so deterministic callbacks just work. + +```python +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import AfterToolCallEvent + +class AuditHook(HookProvider): + def register_hooks(self, registry: HookRegistry) -> None: + registry.add_callback(AfterToolCallEvent, self._on_tool_call) + + def _on_tool_call(self, event: AfterToolCallEvent) -> None: + # Pure local state - deterministic across replay. + workflow.logger.info(f"tool {event.tool_use['name']} finished") + +agent = TemporalAgent(start_to_close_timeout=..., hooks=[AuditHook()]) +``` + +Callbacks run in workflow context, so they must be deterministic: no `time.time()`, `uuid.uuid4()`, or I/O — same rules as workflow code. For callbacks that need I/O (audit logging, metrics, alerting), use `workflow.activity_as_hook()` to dispatch the work as a Temporal activity: + +```python +from temporalio.contrib.strands.workflow import activity_as_hook + +@activity.defn +async def persist_tool_call(tool_name: str) -> None: + # I/O safely in an activity. + ... + +class AuditHook(HookProvider): + def register_hooks(self, registry: HookRegistry) -> None: + registry.add_callback( + AfterToolCallEvent, + activity_as_hook( + persist_tool_call, + activity_input=lambda event: event.tool_use["name"], + start_to_close_timeout=timedelta(seconds=10), + ), + ) +``` + +`activity_input` extracts serializable values from the event to pass as the activity's input. Use a dataclass or Pydantic model for multiple values. This is needed because events hold references to the `Agent`, `AgentTool` instances, etc., none of which cross the activity boundary. + +## Human-in-the-loop interrupts + +Strands offers two HITL surfaces; both work with the plugin. In each case, `agent.invoke_async()` returns `AgentResult(stop_reason="interrupt", interrupts=[...])` instead of raising. Pair this with a signal handler that supplies responses, then resume by calling `agent.invoke_async(responses)`. + +### Hook-based interrupts + +A hook on an interruptible event (e.g. `BeforeToolCallEvent`) can pause the agent by calling `event.interrupt(name, reason=...)`. The hook runs in workflow context, so it must be deterministic — no I/O. + +```python +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import BeforeToolCallEvent + +class ApprovalHook(HookProvider): + def register_hooks(self, registry: HookRegistry) -> None: + registry.add_callback(BeforeToolCallEvent, self._gate) + + def _gate(self, event: BeforeToolCallEvent) -> None: + if event.interrupt("approval", reason="confirm delete") != "approve": + event.cancel_tool = "denied" + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[delete_thing], + hooks=[ApprovalHook()], + ) + self._approval: str | None = None + + @workflow.signal + def approve(self, response: str) -> None: + self._approval = response + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + if result.stop_reason == "interrupt": + await workflow.wait_condition(lambda: self._approval is not None) + result = await self.agent.invoke_async([ + {"interruptResponse": {"interruptId": result.interrupts[0].id, "response": self._approval}} + ]) + return str(result) +``` + +### Tool-body interrupts + +A `@strands.tool` function can raise `InterruptException(Interrupt(...))` directly. The agent stops with the interrupt, the workflow handles the resume the same way as for hooks. + +```python +from strands import tool +from strands.interrupt import Interrupt, InterruptException + +@tool +def delete_thing(name: str) -> str: + raise InterruptException( + Interrupt(id=f"delete:{name}", name="approval", reason=f"delete {name}?") + ) +``` + +The same works from an `activity_as_tool`-wrapped activity. The plugin's failure converter preserves the `Interrupt` payload across the activity boundary, so `AgentResult.interrupts` is populated just like the in-workflow case: + +```python +from strands.interrupt import Interrupt, InterruptException +from temporalio.contrib.strands.workflow import activity_as_tool + +@activity.defn +async def delete_thing(name: str) -> str: + if not await policy.is_authorized(name): + raise InterruptException( + Interrupt(id=f"delete:{name}", name="approval", reason=f"delete {name}?") + ) + await storage.delete(name) + return f"deleted {name}" + +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[activity_as_tool(delete_thing, start_to_close_timeout=timedelta(seconds=10))], + ) +``` + +This relies on the plugin's failure converter, which is installed via the client's data converter. **Attach `StrandsPlugin` to the client** (not just the worker) for activity-tool interrupts to work — workers built from that client pick up the plugin automatically. + +```python +client = await Client.connect("localhost:7233", plugins=[StrandsPlugin(models=MODELS)]) +Worker(client, task_queue="strands", workflows=[MyWorkflow], activities=[delete_thing]) +``` + +## Continue-as-new + +A chat-style workflow accumulates history with every turn and will eventually hit Temporal's per-workflow history limit. `workflow.info().is_continue_as_new_suggested()` flips true once the server decides history has grown large enough; check it after each turn and hand off to a fresh run, carrying `agent.messages` as input: + +```python +from dataclasses import dataclass, field +from strands.types.content import Messages + +@dataclass +class ChatInput: + messages: Messages = field(default_factory=list) + +@workflow.defn +class ChatWorkflow: + def __init__(self) -> None: + self._pending: list[str] = [] + self._done = False + + @workflow.signal + def user_says(self, prompt: str) -> None: + self._pending.append(prompt) + + @workflow.signal + def end_chat(self) -> None: + self._done = True + + @workflow.run + async def run(self, input: ChatInput) -> None: + agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + messages=list(input.messages), + ) + while True: + await workflow.wait_condition(lambda: self._pending or self._done) + if self._done: + return + await agent.invoke_async(self._pending.pop(0)) + if workflow.info().is_continue_as_new_suggested(): + workflow.continue_as_new(ChatInput(messages=agent.messages)) +``` + +## MCP + +`StrandsPlugin(mcp_clients=...)` takes a mapping of `name → MCPClient factory`, mirroring the `models=` pattern. The plugin registers a per-server `{name}-call-tool` activity and connects at worker startup to enumerate tools. Workflow-side, `TemporalMCPClient(server="name")` is a pure handle: it references the server by name and carries the per-call activity options. + +```python +from mcp import StdioServerParameters, stdio_client +from strands.tools.mcp.mcp_client import MCPClient +from temporalio.contrib.strands import TemporalMCPClient + +# workflow +@workflow.defn +class MyWorkflow: + def __init__(self) -> None: + echo = TemporalMCPClient(server="echo", start_to_close_timeout=timedelta(seconds=30)) + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[echo], + ) + +# worker +Worker( + ..., + plugins=[StrandsPlugin( + mcp_clients={ + "echo": lambda: MCPClient( + lambda: stdio_client( + StdioServerParameters(command="...", args=[...]), + ), + ), + }, + )], +) +``` + +Each factory returns a fully configured `MCPClient`, so you can pass options like `tool_filters`, `prefix`, `elicitation_callback`, or `tasks_config` to it. The plugin connects to each MCP server once at worker startup to enumerate tools. The schema is frozen for the worker's lifetime; restart workers to pick up MCP-server changes. If a server is unavailable at startup, the worker fails to start. + +## Observability + +`StrandsPlugin` composes cleanly with [`OpenTelemetryPlugin`](../opentelemetry). Register `OpenTelemetryPlugin` on the client (workers built from that client pick it up automatically) and `StrandsPlugin` on the worker. You'll get OTel spans around the model, tool, and MCP activities the plugin schedules, plus any spans Strands itself emits inside `invoke_async`: + +```python +import opentelemetry.trace +from temporalio.contrib.opentelemetry import OpenTelemetryPlugin, create_tracer_provider + +opentelemetry.trace.set_tracer_provider(create_tracer_provider()) + +client = await Client.connect("localhost:7233", plugins=[OpenTelemetryPlugin()]) + +Worker( + client, + task_queue="strands", + workflows=[MyWorkflow], + plugins=[StrandsPlugin(models=MODELS)], +) +``` + +Set the tracer provider before connecting the client. See the [OpenTelemetry plugin README](../opentelemetry) for exporter setup. diff --git a/temporalio/contrib/strands/__init__.py b/temporalio/contrib/strands/__init__.py new file mode 100644 index 000000000..39a8e7401 --- /dev/null +++ b/temporalio/contrib/strands/__init__.py @@ -0,0 +1,13 @@ +"""Temporal integration for the Strands Agents SDK.""" + +from . import workflow +from ._plugin import StrandsPlugin +from ._temporal_agent import TemporalAgent +from ._temporal_mcp_client import TemporalMCPClient + +__all__ = [ + "StrandsPlugin", + "TemporalAgent", + "TemporalMCPClient", + "workflow", +] diff --git a/temporalio/contrib/strands/_failure_converter.py b/temporalio/contrib/strands/_failure_converter.py new file mode 100644 index 000000000..c387f47f4 --- /dev/null +++ b/temporalio/contrib/strands/_failure_converter.py @@ -0,0 +1,69 @@ +"""Failure converter for Strands-specific exceptions.""" + +from strands.interrupt import InterruptException +from strands.types.exceptions import ( + ContextWindowOverflowException, + MaxTokensReachedException, + SessionException, + StructuredOutputException, +) + +import temporalio.api.failure.v1 +from temporalio.converter import DefaultFailureConverter, PayloadConverter +from temporalio.exceptions import ApplicationError + +# Activity-side: when a Strands ``InterruptException`` would otherwise be +# serialized by the default converter, the ``Interrupt`` payload on +# ``exc.interrupt`` is dropped (it lives on the instance, not in the +# serialized ApplicationError). We translate to a typed ApplicationError so +# the interrupt data survives the activity boundary and the workflow side +# can rebuild a real ``Interrupt``. +STRANDS_INTERRUPT_TYPE = "StrandsInterrupt" + +# Strands' model/session exceptions that are deterministic failures (token +# limits, context overflow, structured-output validation, session I/O). They +# won't succeed on retry, so they cross the boundary as non-retryable typed +# ApplicationErrors. TemporalAgent.invoke_async rewraps these as +# StrandsWorkflowError on the workflow side so users can `except` cleanly. +_TERMINAL_EXCEPTIONS: tuple[type[BaseException], ...] = ( + MaxTokensReachedException, + ContextWindowOverflowException, + StructuredOutputException, + SessionException, +) + + +class StrandsFailureConverter(DefaultFailureConverter): + """Failure converter that preserves Strands exception payloads and retryability.""" + + def to_failure( + self, + exception: BaseException, + payload_converter: PayloadConverter, + failure: temporalio.api.failure.v1.Failure, + ) -> None: + """Translate Strands exceptions to typed ApplicationErrors.""" + if isinstance(exception, InterruptException): + super().to_failure( + ApplicationError( + f"interrupt:{exception.interrupt.name}", + exception.interrupt.to_dict(), + type=STRANDS_INTERRUPT_TYPE, + non_retryable=True, + ), + payload_converter, + failure, + ) + return + if isinstance(exception, _TERMINAL_EXCEPTIONS): + super().to_failure( + ApplicationError( + str(exception), + type=type(exception).__name__, + non_retryable=True, + ), + payload_converter, + failure, + ) + return + super().to_failure(exception, payload_converter, failure) diff --git a/temporalio/contrib/strands/_heartbeat_decorator.py b/temporalio/contrib/strands/_heartbeat_decorator.py new file mode 100644 index 000000000..7c5b9193d --- /dev/null +++ b/temporalio/contrib/strands/_heartbeat_decorator.py @@ -0,0 +1,38 @@ +import asyncio +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any, TypeVar, cast + +from temporalio import activity + +F = TypeVar("F", bound=Callable[..., Awaitable[Any]]) + + +def auto_heartbeater(fn: F) -> F: + """Decorator that heartbeats at half the activity's heartbeat timeout.""" + + @wraps(fn) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + heartbeat_timeout = activity.info().heartbeat_timeout + heartbeat_task = None + if heartbeat_timeout: + heartbeat_task = asyncio.create_task( + _heartbeat_every(heartbeat_timeout.total_seconds() / 2) + ) + try: + return await fn(*args, **kwargs) + finally: + if heartbeat_task: + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + + return cast(F, wrapper) + + +async def _heartbeat_every(delay: float) -> None: + while True: + await asyncio.sleep(delay) + activity.heartbeat() diff --git a/temporalio/contrib/strands/_model_activity.py b/temporalio/contrib/strands/_model_activity.py new file mode 100644 index 000000000..fba30658d --- /dev/null +++ b/temporalio/contrib/strands/_model_activity.py @@ -0,0 +1,106 @@ +from collections.abc import AsyncIterable, Callable +from dataclasses import dataclass, field +from datetime import timedelta +from typing import Any + +from strands.models import Model +from strands.types.streaming import StreamEvent + +from temporalio import activity +from temporalio.contrib.strands._heartbeat_decorator import auto_heartbeater +from temporalio.contrib.workflow_streams import WorkflowStreamClient + + +# Fields are typed as Any because strands TypedDicts (Message, ToolSpec) use +# NotRequired, which Python < 3.11's get_type_hints leaks through unchanged +# and the default JSON converter then fails to deserialize. Values flow +# through unchanged to ``Model.stream`` which accepts the raw dicts. +@dataclass +class _InvokeModelInput: + model_name: str | None + messages: Any + invocation_state: dict[str, Any] = field(default_factory=dict) + tool_specs: Any = None + system_prompt: str | None = None + tool_choice: Any = None + system_prompt_content: Any = None + + +@dataclass +class _StreamingInvokeModelInput(_InvokeModelInput): + streaming_topic: str = "" + streaming_batch_interval_seconds: float = 0.1 + + +class ModelActivity: + """Holds the registered model factories and exposes the model activities.""" + + def __init__( + self, + factories: dict[str, Callable[[], Model]], + *, + default_name: str | None = None, + ) -> None: + """Store the factories; models are constructed lazily on first use. + + ``default_name`` is set only by the plugin's own auto-registered + ``BedrockModel`` default. User-supplied ``models`` leave it ``None``, + which forces every ``TemporalAgent`` to specify ``model=`` explicitly. + """ + self._factories = factories + self._default_name = default_name + self._models: dict[str, Model] = {} + + def _get_model(self, name: str | None) -> Model: + if name is None: + if self._default_name is None: + raise ValueError( + f"TemporalAgent was constructed without an explicit `model`, " + f"but the plugin was configured with user-supplied `models=`. " + f"Pass model='...' to TemporalAgent. " + f"Known: {sorted(self._factories)}" + ) + name = self._default_name + if name not in self._models: + if name not in self._factories: + raise ValueError( + f"Unknown model name {name!r}. Known: {sorted(self._factories)}" + ) + self._models[name] = self._factories[name]() + return self._models[name] + + @activity.defn + @auto_heartbeater + async def invoke_model(self, input: _InvokeModelInput) -> list[StreamEvent]: + """Run the named model and return its stream events as a list.""" + model = self._get_model(input.model_name) + return [event async for event in _stream(model, input)] + + @activity.defn + @auto_heartbeater + async def invoke_model_streaming( + self, input: _StreamingInvokeModelInput + ) -> list[StreamEvent]: + """Run the named model and publish each stream event to a WorkflowStream.""" + model = self._get_model(input.model_name) + events: list[StreamEvent] = [] + stream = WorkflowStreamClient.from_within_activity( + batch_interval=timedelta(seconds=input.streaming_batch_interval_seconds), + ) + topic = stream.topic(input.streaming_topic) + async with stream: + async for event in _stream(model, input): + events.append(event) + topic.publish(event) + return events + + +def _stream(model: Model, input: _InvokeModelInput) -> AsyncIterable[StreamEvent]: + return model.stream( + input.messages, + input.tool_specs, + input.system_prompt, + tool_choice=input.tool_choice, + system_prompt_content=input.system_prompt_content, + invocation_state=input.invocation_state, + ) diff --git a/temporalio/contrib/strands/_plugin.py b/temporalio/contrib/strands/_plugin.py new file mode 100644 index 000000000..918883e07 --- /dev/null +++ b/temporalio/contrib/strands/_plugin.py @@ -0,0 +1,110 @@ +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager +from dataclasses import replace + +from strands.models import Model +from strands.models.bedrock import BedrockModel +from strands.tools.mcp.mcp_client import MCPClient + +from temporalio.contrib.pydantic import pydantic_data_converter +from temporalio.converter import DataConverter, DefaultPayloadConverter +from temporalio.plugin import SimplePlugin +from temporalio.worker import WorkflowRunner +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner + +from ._failure_converter import StrandsFailureConverter +from ._model_activity import ModelActivity +from ._temporal_mcp_client import ( + build_call_tool_activity, + clear_cache, + populate_cache, +) + + +class StrandsPlugin(SimplePlugin): + """Temporal Worker plugin for the Strands Agents SDK. + + When ``models`` is supplied, registers a single pair of model invocation + activities; each call carries the chosen ``model_name`` in its input and + the worker resolves it against the factories. Factories are called lazily + on first use, then cached for the worker's lifetime. Use the same name in + ``TemporalAgent(model=...)`` inside the workflow. + + When ``mcp_clients`` is supplied, registers a per-server + ``{server}-call-tool`` activity for each entry and, at worker startup, + connects to each MCP server to cache its tool list. Workflow-side + ``TemporalMCPClient(server="...").load_tools()`` reads from the cache. + """ + + def __init__( + self, + *, + models: dict[str, Callable[[], Model]] | None = None, + mcp_clients: dict[str, Callable[[], MCPClient]] | None = None, + ) -> 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. + """ + default_name: str | None = None + if models is None: + models = {"bedrock": lambda: BedrockModel()} + default_name = "bedrock" + activities: list[Callable] = [] + if models: + ma = ModelActivity(models, default_name=default_name) + activities.extend([ma.invoke_model, ma.invoke_model_streaming]) + + mcp_clients = mcp_clients or {} + for server, client_factory in mcp_clients.items(): + activities.append(build_call_tool_activity(server, client_factory)) + + @asynccontextmanager + async def run_context() -> AsyncGenerator[None, None]: + for server, client_factory in mcp_clients.items(): + await populate_cache(server, client_factory) + try: + yield + finally: + for server in mcp_clients: + clear_cache(server) + + super().__init__( + "aws.StrandsPlugin", + workflow_runner=_workflow_runner, + data_converter=_data_converter, + activities=activities or None, + run_context=run_context, + ) + + +def _workflow_runner(runner: WorkflowRunner | None) -> WorkflowRunner: + if not runner: + raise ValueError("No WorkflowRunner provided to the Strands plugin.") + if isinstance(runner, SandboxedWorkflowRunner): + return replace( + runner, + restrictions=runner.restrictions.with_passthrough_modules( + "strands", + "strands_tools", + "mcp", + # ``pydantic`` is already in the SDK default passthrough; extend it + # to its compiled validation core and ``Annotated`` helper. + "pydantic_core", + "annotated_types", + ), + ) + return runner + + +def _data_converter(converter: DataConverter | None) -> DataConverter: + if ( + converter is None + or converter.payload_converter_class is DefaultPayloadConverter + ): + return replace( + pydantic_data_converter, + failure_converter_class=StrandsFailureConverter, + ) + return converter diff --git a/temporalio/contrib/strands/_temporal_activity_tool.py b/temporalio/contrib/strands/_temporal_activity_tool.py new file mode 100644 index 000000000..bb838834e --- /dev/null +++ b/temporalio/contrib/strands/_temporal_activity_tool.py @@ -0,0 +1,95 @@ +import inspect +import json +from collections.abc import Callable +from typing import Any + +from strands.interrupt import Interrupt +from strands.tools.decorator import FunctionToolMetadata +from strands.types._events import ToolInterruptEvent, ToolResultEvent +from strands.types.tools import AgentTool, ToolGenerator, ToolResult, ToolSpec, ToolUse + +from temporalio import activity, workflow +from temporalio.exceptions import ActivityError, ApplicationError + +from ._failure_converter import STRANDS_INTERRUPT_TYPE + + +class TemporalActivityTool(AgentTool): + """Strands ``AgentTool`` whose body dispatches a Temporal activity.""" + + def __init__(self, activity_fn: Callable, options: dict[str, Any]) -> None: + """Capture the target activity and the options to invoke it with.""" + super().__init__() + defn = activity._Definition.from_callable(activity_fn) + if not defn or not defn.name: + raise ValueError("activity_fn must be decorated with @activity.defn") + self._activity_name = defn.name + self._options = options + self._signature = inspect.signature(activity_fn) + spec = FunctionToolMetadata(activity_fn).extract_metadata() + spec["name"] = self._activity_name + self._spec: ToolSpec = spec + + @property + def tool_name(self) -> str: + """Name of the underlying Temporal activity.""" + return self._activity_name + + @property + def tool_spec(self) -> ToolSpec: + """Strands ToolSpec derived from the activity's signature.""" + return self._spec + + @property + def tool_type(self) -> str: + """Tool kind identifier used by Strands.""" + return "temporal_activity" + + async def stream( + self, + tool_use: ToolUse, + invocation_state: dict[str, Any], + **kwargs: Any, + ) -> ToolGenerator: + """Execute the tool by dispatching to the bound Temporal activity.""" + bound = self._signature.bind(**tool_use["input"]) + bound.apply_defaults() + positional = list(bound.arguments.values()) + try: + if not positional: + result = await workflow.execute_activity( + self._activity_name, **self._options + ) + elif len(positional) == 1: + result = await workflow.execute_activity( + self._activity_name, positional[0], **self._options + ) + else: + result = await workflow.execute_activity( + self._activity_name, args=positional, **self._options + ) + except ActivityError as e: + cause = e.__cause__ + if ( + isinstance(cause, ApplicationError) + and cause.type == STRANDS_INTERRUPT_TYPE + ): + yield ToolInterruptEvent(tool_use, [Interrupt(**cause.details[0])]) + return + raise + yield ToolResultEvent( + ToolResult( + toolUseId=tool_use["toolUseId"], + status="success", + content=[{"text": _to_text(result)}], + ) + ) + + +def _to_text(result: Any) -> str: + if isinstance(result, str): + return result + try: + return json.dumps(result) + except (TypeError, ValueError): + return str(result) diff --git a/temporalio/contrib/strands/_temporal_agent.py b/temporalio/contrib/strands/_temporal_agent.py new file mode 100644 index 000000000..9bc1beb31 --- /dev/null +++ b/temporalio/contrib/strands/_temporal_agent.py @@ -0,0 +1,85 @@ +from datetime import timedelta +from typing import Any + +from strands import Agent + +from temporalio.common import Priority, RetryPolicy +from temporalio.workflow import ActivityCancellationType, VersioningIntent + +from ._temporal_model import TemporalModel + +_SNAPSHOT_DISABLED = ( + "TemporalAgent disables take_snapshot()/load_snapshot(). Temporal " + "workflows already persist agent state durably via the event history at " + "a finer granularity than Strands snapshots. Remove the snapshot call " + "and rely on Temporal's durable execution instead." +) + + +class TemporalAgent(Agent): + """A Strands ``Agent`` that routes model calls through a Temporal activity. + + ``model`` is the name of a factory registered in + ``StrandsPlugin(models={...})``. The activity options apply to every model + invocation this agent makes. All other keyword arguments are forwarded to + Strands' ``Agent`` (``tools``, ``hooks``, ``system_prompt``, + ``structured_output_model``, ``messages``, etc.). + + Strands' ``retry_strategy`` is disabled; configure retries via + ``retry_policy`` here and on the activity options accepted by + ``activity_as_tool``, ``activity_as_hook``, and ``TemporalMCPClient``. + """ + + def __init__( + self, + *, + model: str | None = None, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: Priority = Priority.default, + streaming_topic: str | None = None, + streaming_batch_interval: timedelta = timedelta(milliseconds=100), + **agent_kwargs: Any, + ) -> None: + """Build a TemporalAgent from a registered model name and activity options.""" + if agent_kwargs.get("retry_strategy") is not None: + raise ValueError( + "TemporalAgent disables Strands retries; configure retries via " + "retry_policy on TemporalAgent and on the activity options " + "passed to workflow.activity_as_tool, workflow.activity_as_hook, " + "or TemporalMCPClient. Remove retry_strategy from " + "TemporalAgent(...) or pass retry_strategy=None." + ) + agent_kwargs["retry_strategy"] = None + + temporal_model = TemporalModel( + model_name=model, + task_queue=task_queue, + schedule_to_close_timeout=schedule_to_close_timeout, + schedule_to_start_timeout=schedule_to_start_timeout, + start_to_close_timeout=start_to_close_timeout, + heartbeat_timeout=heartbeat_timeout, + retry_policy=retry_policy, + cancellation_type=cancellation_type, + versioning_intent=versioning_intent, + summary=summary, + priority=priority, + streaming_topic=streaming_topic, + streaming_batch_interval=streaming_batch_interval, + ) + super().__init__(model=temporal_model, **agent_kwargs) + + def take_snapshot(self, *_args: Any, **_kwargs: Any) -> Any: + """Disabled; Temporal's event history is the source of truth.""" + raise NotImplementedError(_SNAPSHOT_DISABLED) + + def load_snapshot(self, *_args: Any, **_kwargs: Any) -> Any: + """Disabled; Temporal's event history is the source of truth.""" + raise NotImplementedError(_SNAPSHOT_DISABLED) diff --git a/temporalio/contrib/strands/_temporal_mcp_client.py b/temporalio/contrib/strands/_temporal_mcp_client.py new file mode 100644 index 000000000..aa8e25c15 --- /dev/null +++ b/temporalio/contrib/strands/_temporal_mcp_client.py @@ -0,0 +1,183 @@ +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from datetime import timedelta +from typing import Any + +from mcp import ClientSession +from mcp.types import PaginatedRequestParams, Tool +from strands.tools.mcp.mcp_agent_tool import MCPAgentTool +from strands.tools.mcp.mcp_client import MCPClient +from strands.tools.mcp.mcp_types import MCPToolResult +from strands.tools.tool_provider import ToolProvider +from strands.types.tools import AgentTool + +from temporalio import activity +from temporalio.common import Priority, RetryPolicy +from temporalio.workflow import ActivityCancellationType, VersioningIntent + + +@dataclass +class _MCPToolInfo: + name: str + description: str + input_schema: dict[str, Any] + output_schema: dict[str, Any] | None = None + + +@dataclass +class _CallToolArgs: + tool_name: str + arguments: dict[str, Any] = field(default_factory=dict) + tool_use_id: str = "" + + +# Server name -> cached tool list. Populated by ``_populate_cache`` at worker +# startup and read by ``TemporalMCPClient.load_tools()`` inside the workflow +# sandbox. ``temporalio`` is in the SDK's default sandbox passthrough, so this +# dict is shared between worker process and workflow execution. +_TOOL_CACHE: dict[str, list[_MCPToolInfo]] = {} + + +class TemporalMCPClient(ToolProvider): + """Workflow-side handle to an MCP server registered on the worker. + + The transport factory and tool discovery live worker-side via + ``StrandsPlugin(mcp_clients={"server": lambda: ...})``. This handle only + carries the server name (which selects the registered factory) and the + per-call activity options. + + Construct once at module level and pass to ``TemporalAgent(tools=[...])`` + inside the workflow. Multiple handles may reference the same server name + with different activity options. + """ + + def __init__( + self, + server: str, + *, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: Priority = Priority.default, + ) -> None: + """Configure the server name and activity options.""" + self._server = server + self._options: dict[str, Any] = { + "task_queue": task_queue, + "schedule_to_close_timeout": schedule_to_close_timeout, + "schedule_to_start_timeout": schedule_to_start_timeout, + "start_to_close_timeout": start_to_close_timeout, + "heartbeat_timeout": heartbeat_timeout, + "retry_policy": retry_policy, + "cancellation_type": cancellation_type, + "versioning_intent": versioning_intent, + "summary": summary, + "priority": priority, + } + + @property + def server(self) -> str: + """MCP server name used as the activity prefix.""" + return self._server + + async def load_tools(self, **_kwargs: Any) -> Sequence[AgentTool]: + """Return TemporalMCPTool wrappers for tools cached at worker startup.""" + from ._temporal_mcp_tool import TemporalMCPTool + + infos = _TOOL_CACHE.get(self._server, []) + return [TemporalMCPTool(self._server, info, self._options) for info in infos] + + def add_consumer(self, consumer_id: Any, **_kwargs: Any) -> None: + """No-op; consumer tracking is handled by the underlying MCP client.""" + return None + + def remove_consumer(self, consumer_id: Any, **_kwargs: Any) -> None: + """No-op; consumer tracking is handled by the underlying MCP client.""" + return None + + +# Use MCP sessions directly instead of MCPClient's background-thread helpers. +# Those helpers route calls through cross-loop futures that are unreliable on +# Python 3.10 when invoked from Temporal's async worker/activity event loops. +async def _list_mcp_tools(client: MCPClient) -> Sequence[Tool]: + async with client._transport_callable() as (read_stream, write_stream, *_): + async with ClientSession( + read_stream, + write_stream, + elicitation_callback=client._elicitation_callback, + ) as session: + await session.initialize() + tools: list[Tool] = [] + pagination_token = None + while True: + page = await session.list_tools( + params=PaginatedRequestParams(cursor=pagination_token) + if pagination_token is not None + else None + ) + tools.extend(page.tools) + pagination_token = page.nextCursor + if pagination_token is None: + return tools + + +def _agent_tool_for_filtering(client: MCPClient, tool: Tool) -> MCPAgentTool: + if client._prefix: + return MCPAgentTool(tool, client, name_override=f"{client._prefix}_{tool.name}") + return MCPAgentTool(tool, client) + + +async def populate_cache(server: str, client_factory: Callable[[], MCPClient]) -> None: + """Connect to the MCP server, list tools, fill ``_TOOL_CACHE``.""" + client = client_factory() + infos: list[_MCPToolInfo] = [] + for tool in await _list_mcp_tools(client): + if not client._should_include_tool_with_filters( + _agent_tool_for_filtering(client, tool), + client._tool_filters, + ): + continue + infos.append( + _MCPToolInfo( + name=tool.name, + description=tool.description or "", + input_schema=tool.inputSchema, + output_schema=tool.outputSchema, + ) + ) + _TOOL_CACHE[server] = infos + + +def clear_cache(server: str) -> None: + """Drop the cached tool list for ``server``.""" + _TOOL_CACHE.pop(server, None) + + +def build_call_tool_activity( + server: str, client_factory: Callable[[], MCPClient] +) -> Callable: + """Return the per-server ``{server}-call-tool`` activity for registration.""" + + @activity.defn(name=f"{server}-call-tool") + async def call_tool(args: _CallToolArgs) -> MCPToolResult: + client = client_factory() + try: + async with client._transport_callable() as (read_stream, write_stream, *_): + async with ClientSession( + read_stream, + write_stream, + elicitation_callback=client._elicitation_callback, + ) as session: + await session.initialize() + result = await session.call_tool(args.tool_name, args.arguments) + return client._handle_tool_result(args.tool_use_id, result) + except Exception as err: + return client._handle_tool_execution_error(args.tool_use_id, err) + + return call_tool diff --git a/temporalio/contrib/strands/_temporal_mcp_tool.py b/temporalio/contrib/strands/_temporal_mcp_tool.py new file mode 100644 index 000000000..885b1a7e2 --- /dev/null +++ b/temporalio/contrib/strands/_temporal_mcp_tool.py @@ -0,0 +1,65 @@ +from typing import Any + +from strands.types._events import ToolResultEvent +from strands.types.tools import AgentTool, ToolGenerator, ToolResult, ToolSpec, ToolUse + +from temporalio import workflow + +from ._temporal_mcp_client import _CallToolArgs, _MCPToolInfo + + +class TemporalMCPTool(AgentTool): + """Workflow-side stub for a single MCP tool; dispatches to an activity.""" + + def __init__( + self, + server: str, + info: _MCPToolInfo, + options: dict[str, Any], + ) -> None: + """Bind this tool to a server, its cached info, and activity options.""" + super().__init__() + self._server = server + self._info = info + self._options = options + + @property + def tool_name(self) -> str: + """Name of the underlying MCP tool.""" + return self._info.name + + @property + def tool_spec(self) -> ToolSpec: + """Strands ToolSpec built from the cached MCP tool info.""" + spec: ToolSpec = { + "name": self._info.name, + "description": self._info.description + or f"Tool which performs {self._info.name}", + "inputSchema": {"json": self._info.input_schema}, + } + if self._info.output_schema: + spec["outputSchema"] = {"json": self._info.output_schema} + return spec + + @property + def tool_type(self) -> str: + """Tool kind identifier used by Strands.""" + return "temporal_mcp" + + async def stream( + self, + tool_use: ToolUse, + invocation_state: dict[str, Any], + **kwargs: Any, + ) -> ToolGenerator: + """Execute the tool by dispatching to the per-server call-tool activity.""" + result: ToolResult = await workflow.execute_activity( + f"{self._server}-call-tool", + _CallToolArgs( + tool_name=self._info.name, + arguments=tool_use["input"], + tool_use_id=tool_use["toolUseId"], + ), + **self._options, + ) + yield ToolResultEvent(result) diff --git a/temporalio/contrib/strands/_temporal_model.py b/temporalio/contrib/strands/_temporal_model.py new file mode 100644 index 000000000..29e5c63a2 --- /dev/null +++ b/temporalio/contrib/strands/_temporal_model.py @@ -0,0 +1,148 @@ +import json +from collections.abc import AsyncIterable +from datetime import timedelta +from typing import Any + +from strands.models import Model +from strands.types.content import Messages, SystemContentBlock +from strands.types.streaming import StreamEvent +from strands.types.tools import ToolChoice, ToolSpec + +from temporalio import workflow +from temporalio.common import Priority, RetryPolicy +from temporalio.workflow import ActivityCancellationType, VersioningIntent + +from ._model_activity import ( + ModelActivity, + _InvokeModelInput, + _StreamingInvokeModelInput, +) + + +def _filter_serializable(state: dict[str, Any]) -> dict[str, Any]: + """Keep invocation_state entries that JSON-serialize; drop the rest with a debug log.""" + clean: dict[str, Any] = {} + dropped: list[str] = [] + for key, value in state.items(): + try: + json.dumps(value) + except (TypeError, ValueError): + dropped.append(key) + continue + clean[key] = value + if dropped: + workflow.logger.debug( + f"Dropping non-serializable invocation_state keys: {dropped}" + ) + return clean + + +class TemporalModel(Model): + """A Strands ``Model`` that runs ``stream()`` as a Temporal activity. + + ``model_name`` selects which factory the plugin will invoke worker-side; it + must match a key in ``StrandsPlugin(models={...})``. Construction of this + ``TemporalModel`` itself does no I/O, so it is safe to instantiate at + module level. + + When ``streaming_topic`` is set, each ``StreamEvent`` is also published to + the named topic on the workflow's + :class:`temporalio.contrib.workflow_streams.WorkflowStream` for external + consumers. + """ + + def __init__( + self, + model_name: str | None = None, + *, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: Priority = Priority.default, + streaming_topic: str | None = None, + streaming_batch_interval: timedelta = timedelta(milliseconds=100), + ) -> None: + """Configure the model name, activity options, and streaming settings.""" + self._model_name = model_name + self._streaming_topic = streaming_topic + self._streaming_batch_interval = streaming_batch_interval + self._options: dict[str, Any] = { + "task_queue": task_queue, + "schedule_to_close_timeout": schedule_to_close_timeout, + "schedule_to_start_timeout": schedule_to_start_timeout, + "start_to_close_timeout": start_to_close_timeout, + "heartbeat_timeout": heartbeat_timeout, + "retry_policy": retry_policy, + "cancellation_type": cancellation_type, + "versioning_intent": versioning_intent, + "summary": summary, + "priority": priority, + } + + def update_config(self, **_model_config: Any) -> None: + """No-op; the real model is configured worker-side via the plugin's factories.""" + return None + + def get_config(self) -> dict[str, Any]: + """Return an empty config; configuration lives on the worker-side model.""" + return {} + + def structured_output(self, *_args: Any, **_kwargs: Any) -> Any: + """Not supported; use ``TemporalAgent(structured_output_model=...)`` instead.""" + raise NotImplementedError( + "TemporalModel.structured_output is not supported. Use " + "TemporalAgent(structured_output_model=...) which routes structured " + "output through stream() via the structured_output_tool." + ) + + async def stream( + self, + messages: Messages, + tool_specs: list[ToolSpec] | None = None, + system_prompt: str | None = None, + *, + tool_choice: ToolChoice | None = None, + system_prompt_content: list[SystemContentBlock] | None = None, + invocation_state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncIterable[StreamEvent]: + """Run the model via the registered Temporal activity and yield events.""" + clean_state = _filter_serializable(invocation_state) if invocation_state else {} + if self._streaming_topic is not None: + events = await workflow.execute_activity_method( + ModelActivity.invoke_model_streaming, + _StreamingInvokeModelInput( + model_name=self._model_name, + messages=messages, + invocation_state=clean_state, + tool_specs=tool_specs, + system_prompt=system_prompt, + tool_choice=tool_choice, + system_prompt_content=system_prompt_content, + streaming_topic=self._streaming_topic, + streaming_batch_interval_seconds=self._streaming_batch_interval.total_seconds(), + ), + **self._options, + ) + else: + events = await workflow.execute_activity_method( + ModelActivity.invoke_model, + _InvokeModelInput( + model_name=self._model_name, + messages=messages, + invocation_state=clean_state, + tool_specs=tool_specs, + system_prompt=system_prompt, + tool_choice=tool_choice, + system_prompt_content=system_prompt_content, + ), + **self._options, + ) + for event in events: + yield event diff --git a/temporalio/contrib/strands/workflow.py b/temporalio/contrib/strands/workflow.py new file mode 100644 index 000000000..e6be1a9df --- /dev/null +++ b/temporalio/contrib/strands/workflow.py @@ -0,0 +1,103 @@ +"""Helpers for wiring Temporal activities into Strands' agent and hook surfaces. + +Both ``activity_as_tool`` and ``activity_as_hook`` produce workflow-side objects +that dispatch user activities via :func:`temporalio.workflow.execute_activity`, +so the I/O actually happens off the workflow. +""" + +from collections.abc import Callable +from datetime import timedelta +from typing import Any, TypeVar + +from strands.hooks.registry import BaseHookEvent, HookCallback +from strands.types.tools import AgentTool + +from temporalio import workflow +from temporalio.common import Priority, RetryPolicy +from temporalio.workflow import ActivityCancellationType, VersioningIntent + +from ._temporal_activity_tool import TemporalActivityTool + + +def activity_as_tool( + activity_fn: Callable, + *, + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: Priority = Priority.default, +) -> AgentTool: + """Wrap a Temporal activity as a Strands tool. + + ``activity_fn`` must be decorated by ``@activity.defn``. All keyword + arguments are forwarded to ``workflow.execute_activity``. + """ + options: dict[str, Any] = { + "task_queue": task_queue, + "schedule_to_close_timeout": schedule_to_close_timeout, + "schedule_to_start_timeout": schedule_to_start_timeout, + "start_to_close_timeout": start_to_close_timeout, + "heartbeat_timeout": heartbeat_timeout, + "retry_policy": retry_policy, + "cancellation_type": cancellation_type, + "activity_id": activity_id, + "versioning_intent": versioning_intent, + "summary": summary, + "priority": priority, + } + return TemporalActivityTool(activity_fn, options) + + +TEvent = TypeVar("TEvent", bound=BaseHookEvent) + + +def activity_as_hook( + activity_fn: Callable, + *, + activity_input: Callable[[TEvent], Any], + task_queue: str | None = None, + schedule_to_close_timeout: timedelta | None = None, + schedule_to_start_timeout: timedelta | None = None, + start_to_close_timeout: timedelta | None = None, + heartbeat_timeout: timedelta | None = None, + retry_policy: RetryPolicy | None = None, + cancellation_type: ActivityCancellationType = ActivityCancellationType.TRY_CANCEL, + activity_id: str | None = None, + versioning_intent: VersioningIntent | None = None, + summary: str | None = None, + priority: Priority = Priority.default, +) -> HookCallback[TEvent]: + """Wrap a Temporal activity as a Strands hook callback. + + The returned coroutine, when registered with ``HookRegistry.add_callback``, + dispatches ``activity_fn`` as a Temporal activity each time the associated + event fires. ``activity_input`` is called with the event to produce a + serializable activity input — events themselves are not serializable, since + they hold references to the ``Agent`` and other workflow-bound objects. + All other keyword arguments are forwarded to ``workflow.execute_activity``. + """ + options: dict[str, Any] = { + "task_queue": task_queue, + "schedule_to_close_timeout": schedule_to_close_timeout, + "schedule_to_start_timeout": schedule_to_start_timeout, + "start_to_close_timeout": start_to_close_timeout, + "heartbeat_timeout": heartbeat_timeout, + "retry_policy": retry_policy, + "cancellation_type": cancellation_type, + "activity_id": activity_id, + "versioning_intent": versioning_intent, + "summary": summary, + "priority": priority, + } + + async def callback(event: TEvent) -> None: + await workflow.execute_activity(activity_fn, activity_input(event), **options) + + return callback diff --git a/tests/contrib/strands/common.py b/tests/contrib/strands/common.py new file mode 100644 index 000000000..5ece12a1e --- /dev/null +++ b/tests/contrib/strands/common.py @@ -0,0 +1,10 @@ +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHistory + + +def get_activities(history: WorkflowHistory) -> list[str]: + return [ + event.activity_task_scheduled_event_attributes.activity_type.name + for event in history.events + if event.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED + ] diff --git a/tests/contrib/strands/echo_mcp_server.py b/tests/contrib/strands/echo_mcp_server.py new file mode 100644 index 000000000..9f70075ac --- /dev/null +++ b/tests/contrib/strands/echo_mcp_server.py @@ -0,0 +1,13 @@ +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("echo-server") + + +@mcp.tool() +def echo(message: str) -> str: + """Return the input message unchanged.""" + return message + + +if __name__ == "__main__": + mcp.run() diff --git a/tests/contrib/strands/mock_model.py b/tests/contrib/strands/mock_model.py new file mode 100644 index 000000000..5cbb0f89f --- /dev/null +++ b/tests/contrib/strands/mock_model.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterable +from typing import Any + +from strands.models import Model +from strands.types.streaming import StreamEvent + + +class MockModel(Model): + """Scripted Strands ``Model`` for tests. + + Each entry in ``responses`` is consumed by one ``stream()`` call. A ``str`` + yields a text turn; a ``dict`` of ``{name, input}`` yields a tool-use turn. + """ + + def __init__(self, responses: list[str | dict[str, Any]]) -> None: + self._responses = list(responses) + self._tool_call_index = 0 + + def update_config(self, **_model_config: Any) -> None: + return None + + def get_config(self) -> dict[str, Any]: + return {} + + def structured_output(self, *_args: Any, **_kwargs: Any): + raise NotImplementedError + + async def stream(self, *_args: Any, **_kwargs: Any) -> AsyncIterable[StreamEvent]: + if not self._responses: + raise AssertionError("MockModel script exhausted") + response = self._responses.pop(0) + + yield {"messageStart": {"role": "assistant"}} + + if isinstance(response, str): + yield {"contentBlockDelta": {"delta": {"text": response}}} + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} + else: + self._tool_call_index += 1 + yield { + "contentBlockStart": { + "start": { + "toolUse": { + "name": response["name"], + "toolUseId": f"mock-tool-{self._tool_call_index}", + }, + }, + }, + } + yield { + "contentBlockDelta": { + "delta": {"toolUse": {"input": json.dumps(response["input"])}}, + }, + } + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "tool_use"}} diff --git a/tests/contrib/strands/test_hooks.py b/tests/contrib/strands/test_hooks.py new file mode 100644 index 000000000..19976cb44 --- /dev/null +++ b/tests/contrib/strands/test_hooks.py @@ -0,0 +1,106 @@ +from datetime import timedelta +from uuid import uuid4 + +from strands import tool +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import AfterToolCallEvent + +from temporalio import activity, workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.contrib.strands.workflow import activity_as_hook +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.common import get_activities +from tests.contrib.strands.mock_model import MockModel + +# Module-level sink: written by the audit activity, read in assertions. +# Activity bodies run in worker context, not the sandbox, so a plain list is fine. +_AUDIT_LOG: list[str] = [] + + +@activity.defn +async def audit_tool(tool_name: str) -> None: + _AUDIT_LOG.append(tool_name) + + +@tool +def echo(text: str) -> str: + return text + + +class AuditHook(HookProvider): + def __init__(self) -> None: + self.fired_events: list[str] = [] + + def register_hooks(self, registry: HookRegistry, **kwargs: object) -> None: + registry.add_callback(AfterToolCallEvent, self._sync_log) + registry.add_callback( + AfterToolCallEvent, + activity_as_hook( + audit_tool, + activity_input=lambda event: event.tool_use["name"], + start_to_close_timeout=timedelta(seconds=10), + ), + ) + + def _sync_log(self, event: AfterToolCallEvent) -> None: + # Deterministic in-workflow mutation: appends to per-workflow state. + self.fired_events.append(event.tool_use["name"]) + + +@workflow.defn +class HooksWorkflow: + def __init__(self) -> None: + self.hook = AuditHook() + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + tools=[echo], + hooks=[self.hook], + ) + + @workflow.run + async def run(self, prompt: str) -> list[str]: + await self.agent.invoke_async(prompt) + return self.hook.fired_events + + +async def test_hooks(client: Client): + _AUDIT_LOG.clear() + task_queue = "test_hooks" + plugin = StrandsPlugin( + models={ + "mock": lambda: MockModel( + [ + {"name": "echo", "input": {"text": "hi"}}, + "Done!", + ] + ) + } + ) + + async with Worker( + client, + task_queue=task_queue, + workflows=[HooksWorkflow], + activities=[audit_tool], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + HooksWorkflow.run, + "Say hi", + id=f"test_hooks_{uuid4()}", + task_queue=task_queue, + ) + assert await handle.result() == ["echo"] + + assert _AUDIT_LOG == ["echo"] + + history = await handle.fetch_history() + assert "audit_tool" in get_activities(history) + + await Replayer( + workflows=[HooksWorkflow], + plugins=[plugin], + ).replay_workflow(history) diff --git a/tests/contrib/strands/test_interrupt.py b/tests/contrib/strands/test_interrupt.py new file mode 100644 index 000000000..64f72bc07 --- /dev/null +++ b/tests/contrib/strands/test_interrupt.py @@ -0,0 +1,105 @@ +from datetime import timedelta +from uuid import uuid4 + +from strands import tool +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import BeforeToolCallEvent +from strands.types.interrupt import InterruptResponseContent + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.common import get_activities +from tests.contrib.strands.mock_model import MockModel + + +@tool +def delete_thing(name: str) -> str: + return f"deleted {name}" + + +class ApprovalHook(HookProvider): + def register_hooks(self, registry: HookRegistry, **kwargs: object) -> None: + registry.add_callback(BeforeToolCallEvent, self._gate) + + def _gate(self, event: BeforeToolCallEvent) -> None: + if event.tool_use["name"] != "delete_thing": + return + approval = event.interrupt( + "approval", + reason=f"approve delete of {event.tool_use['input']['name']}?", + ) + if approval != "approve": + event.cancel_tool = "denied" + + +@workflow.defn +class InterruptWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + tools=[delete_thing], + hooks=[ApprovalHook()], + ) + self._approval: str | None = None + + @workflow.signal + def approve(self, response: str) -> None: + self._approval = response + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + while result.stop_reason == "interrupt": + await workflow.wait_condition(lambda: self._approval is not None) + response = self._approval + self._approval = None + responses: list[InterruptResponseContent] = [ + {"interruptResponse": {"interruptId": i.id, "response": response}} + for i in (result.interrupts or []) + ] + result = await self.agent.invoke_async(responses) + return str(result) + + +async def test_interrupt(client: Client): + task_queue = "test_interrupt" + plugin = StrandsPlugin( + models={ + "mock": lambda: MockModel( + [ + {"name": "delete_thing", "input": {"name": "foo"}}, + "Done!", + ] + ) + } + ) + + async with Worker( + client, + task_queue=task_queue, + workflows=[InterruptWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + InterruptWorkflow.run, + "delete foo", + id=f"test_interrupt_{uuid4()}", + task_queue=task_queue, + ) + await handle.signal(InterruptWorkflow.approve, "approve") + assert await handle.result() == "Done!\n" + + history = await handle.fetch_history() + assert get_activities(history) == [ + "invoke_model", + "invoke_model", + ] + + await Replayer( + workflows=[InterruptWorkflow], + plugins=[plugin], + ).replay_workflow(history) diff --git a/tests/contrib/strands/test_interrupt_exception.py b/tests/contrib/strands/test_interrupt_exception.py new file mode 100644 index 000000000..ed858b32b --- /dev/null +++ b/tests/contrib/strands/test_interrupt_exception.py @@ -0,0 +1,191 @@ +from datetime import timedelta +from uuid import uuid4 + +from strands import tool +from strands.interrupt import Interrupt, InterruptException +from strands.types.interrupt import InterruptResponseContent + +from temporalio import activity, workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.contrib.strands.workflow import activity_as_tool +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.common import get_activities +from tests.contrib.strands.mock_model import MockModel + + +@tool +def in_workflow_delete(name: str) -> str: + raise InterruptException( + Interrupt(id=f"delete:{name}", name="approval", reason=f"delete {name}?") + ) + + +# Counts attempts so the activity raises on the first invocation and succeeds on +# the second — modeling a real "approval flipped an external flag" check. +_activity_delete_calls = 0 + + +@activity.defn +async def activity_delete(name: str) -> str: + global _activity_delete_calls + _activity_delete_calls += 1 + if _activity_delete_calls == 1: + raise InterruptException( + Interrupt(id=f"delete:{name}", name="approval", reason=f"delete {name}?") + ) + return f"deleted {name}" + + +@workflow.defn +class InWorkflowToolInterruptWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + tools=[in_workflow_delete], + ) + self._approval: str | None = None + + @workflow.signal + def approve(self, response: str) -> None: + self._approval = response + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + while result.stop_reason == "interrupt": + await workflow.wait_condition(lambda: self._approval is not None) + response, self._approval = self._approval, None + responses: list[InterruptResponseContent] = [ + {"interruptResponse": {"interruptId": i.id, "response": response}} + for i in (result.interrupts or []) + ] + result = await self.agent.invoke_async(responses) + return str(result) + + +@workflow.defn +class ActivityToolInterruptWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + tools=[ + activity_as_tool( + activity_delete, + start_to_close_timeout=timedelta(seconds=15), + ) + ], + ) + self._approval: str | None = None + + @workflow.signal + def approve(self, response: str) -> None: + self._approval = response + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + while result.stop_reason == "interrupt": + await workflow.wait_condition(lambda: self._approval is not None) + response, self._approval = self._approval, None + responses: list[InterruptResponseContent] = [ + {"interruptResponse": {"interruptId": i.id, "response": response}} + for i in (result.interrupts or []) + ] + result = await self.agent.invoke_async(responses) + return str(result) + + +async def test_in_workflow_tool_interrupt(client: Client): + task_queue = "test_in_workflow_tool_interrupt" + plugin = StrandsPlugin( + models={ + "mock": lambda: MockModel( + [ + {"name": "in_workflow_delete", "input": {"name": "foo"}}, + "Done!", + ] + ) + } + ) + + async with Worker( + client, + task_queue=task_queue, + workflows=[InWorkflowToolInterruptWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + InWorkflowToolInterruptWorkflow.run, + "delete foo", + id=f"test_in_workflow_tool_interrupt_{uuid4()}", + task_queue=task_queue, + ) + await handle.signal(InWorkflowToolInterruptWorkflow.approve, "approve") + assert await handle.result() == "Done!\n" + + history = await handle.fetch_history() + # No activity call for the in-workflow @tool — only model calls. + assert get_activities(history) == ["invoke_model", "invoke_model"] + + await Replayer( + workflows=[InWorkflowToolInterruptWorkflow], + plugins=[plugin], + ).replay_workflow(history) + + +async def test_activity_tool_interrupt(client: Client): + global _activity_delete_calls + _activity_delete_calls = 0 + task_queue = "test_activity_tool_interrupt" + plugin = StrandsPlugin( + models={ + "mock": lambda: MockModel( + [ + {"name": "activity_delete", "input": {"name": "foo"}}, + "Done!", + ] + ) + } + ) + + # Activity-side InterruptException relies on the failure converter installed + # via the data converter, which _ActivityWorker reads from the client config. + # Re-create the client with the plugin attached so that converter takes effect. + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[ActivityToolInterruptWorkflow], + activities=[activity_delete], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + ActivityToolInterruptWorkflow.run, + "delete foo", + id=f"test_activity_tool_interrupt_{uuid4()}", + task_queue=task_queue, + ) + await handle.signal(ActivityToolInterruptWorkflow.approve, "approve") + assert await handle.result() == "Done!\n" + + history = await handle.fetch_history() + # activity_delete appears twice: once for the call that raised + # InterruptException, once for the resume call that returned successfully. + assert get_activities(history) == [ + "invoke_model", + "activity_delete", + "activity_delete", + "invoke_model", + ] + + await Replayer( + workflows=[ActivityToolInterruptWorkflow], + plugins=[plugin], + ).replay_workflow(history) diff --git a/tests/contrib/strands/test_invocation_state.py b/tests/contrib/strands/test_invocation_state.py new file mode 100644 index 000000000..01fd4e004 --- /dev/null +++ b/tests/contrib/strands/test_invocation_state.py @@ -0,0 +1,82 @@ +from collections.abc import AsyncIterable +from datetime import timedelta +from typing import Any +from uuid import uuid4 + +from strands.models import Model +from strands.types.streaming import StreamEvent + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.worker import Worker + +# Worker-side sink: the recording model writes the invocation_state it +# received here so the test body can inspect it after the workflow completes. +_RECEIVED: list[dict[str, Any]] = [] + + +class _RecordingModel(Model): + def update_config(self, **_model_config: Any) -> None: + return None + + def get_config(self) -> dict[str, Any]: + return {} + + def structured_output(self, *_args: Any, **_kwargs: Any) -> Any: + raise NotImplementedError + + async def stream( + self, + *_args: Any, + invocation_state: dict[str, Any] | None = None, + **_kwargs: Any, + ) -> AsyncIterable[StreamEvent]: + _RECEIVED.append(invocation_state or {}) + yield {"messageStart": {"role": "assistant"}} + yield {"contentBlockDelta": {"delta": {"text": "ok"}}} + yield {"contentBlockStop": {}} + yield {"messageStop": {"stopReason": "end_turn"}} + + +@workflow.defn +class _InvocationStateWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + model="recording", + start_to_close_timeout=timedelta(seconds=15), + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async( + prompt, + invocation_state={"user_key": "user_value", "non_json": object()}, + ) + return str(result) + + +async def test_invocation_state_round_trip(client: Client): + _RECEIVED.clear() + plugin = StrandsPlugin(models={"recording": lambda: _RecordingModel()}) + + async with Worker( + client, + task_queue="test_invocation_state", + workflows=[_InvocationStateWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + await client.execute_workflow( + _InvocationStateWorkflow.run, + "hi", + id=f"test_invocation_state_{uuid4()}", + task_queue="test_invocation_state", + ) + + # The serializable key crosses the activity boundary; the non-serializable + # one is dropped before dispatch (with a debug log). + assert _RECEIVED, "model.stream() was not called" + received = _RECEIVED[0] + assert received.get("user_key") == "user_value" + assert "non_json" not in received diff --git a/tests/contrib/strands/test_mcp.py b/tests/contrib/strands/test_mcp.py new file mode 100644 index 000000000..ab8fffcbc --- /dev/null +++ b/tests/contrib/strands/test_mcp.py @@ -0,0 +1,88 @@ +import sys +from datetime import timedelta +from pathlib import Path +from uuid import uuid4 + +from mcp import StdioServerParameters, stdio_client +from strands.tools.mcp.mcp_client import MCPClient + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import ( + StrandsPlugin, + TemporalAgent, + TemporalMCPClient, +) +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.common import get_activities +from tests.contrib.strands.mock_model import MockModel + + +@workflow.defn +class MCPWorkflow: + def __init__(self) -> None: + echo = TemporalMCPClient( + server="echo", + start_to_close_timeout=timedelta(seconds=30), + ) + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=30), + tools=[echo], + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) + + +async def test_mcp(client: Client): + task_queue = "test_mcp" + plugin = StrandsPlugin( + models={ + "mock": lambda: MockModel( + [ + {"name": "echo", "input": {"message": "hello"}}, + "Done!", + ] + ) + }, + mcp_clients={ + "echo": lambda: MCPClient( + lambda: stdio_client( + StdioServerParameters( + command=sys.executable, + args=[str(Path(__file__).parent / "echo_mcp_server.py")], + ) + ) + ), + }, + ) + + async with Worker( + client, + task_queue=task_queue, + workflows=[MCPWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + MCPWorkflow.run, + "echo hello", + id=f"test_mcp_{uuid4()}", + task_queue=task_queue, + ) + assert await handle.result() == "Done!\n" + + history = await handle.fetch_history() + assert get_activities(history) == [ + "invoke_model", + "echo-call-tool", + "invoke_model", + ] + + await Replayer( + workflows=[MCPWorkflow], + plugins=[plugin], + ).replay_workflow(history) diff --git a/tests/contrib/strands/test_model.py b/tests/contrib/strands/test_model.py new file mode 100644 index 000000000..68d578e5c --- /dev/null +++ b/tests/contrib/strands/test_model.py @@ -0,0 +1,51 @@ +from datetime import timedelta +from uuid import uuid4 + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.common import get_activities +from tests.contrib.strands.mock_model import MockModel + + +@workflow.defn +class ModelWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) + + +async def test_model(client: Client): + task_queue = "test_model" + plugin = StrandsPlugin(models={"mock": lambda: MockModel(["Done!"])}) + + async with Worker( + client, + task_queue=task_queue, + workflows=[ModelWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + ModelWorkflow.run, + "Hello", + id=f"test_model_{uuid4()}", + task_queue=task_queue, + ) + assert await handle.result() == "Done!\n" + + history = await handle.fetch_history() + assert get_activities(history) == ["invoke_model"] + + await Replayer( + workflows=[ModelWorkflow], + plugins=[plugin], + ).replay_workflow(history) diff --git a/tests/contrib/strands/test_model_streaming.py b/tests/contrib/strands/test_model_streaming.py new file mode 100644 index 000000000..41f3ff2f1 --- /dev/null +++ b/tests/contrib/strands/test_model_streaming.py @@ -0,0 +1,78 @@ +import asyncio +from datetime import timedelta +from uuid import uuid4 + +from strands.types.streaming import StreamEvent + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.contrib.workflow_streams import WorkflowStream, WorkflowStreamClient +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.common import get_activities +from tests.contrib.strands.mock_model import MockModel + + +@workflow.defn +class StreamingModelWorkflow: + def __init__(self) -> None: + self.stream = WorkflowStream() + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + streaming_topic="events", + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) + + +async def test_model_streaming(client: Client): + task_queue = "test_model_streaming" + plugin = StrandsPlugin(models={"mock": lambda: MockModel(["Done!"])}) + workflow_id = f"test_model_streaming_{uuid4()}" + + async with Worker( + client, + task_queue=task_queue, + workflows=[StreamingModelWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + StreamingModelWorkflow.run, + "Hello", + id=workflow_id, + task_queue=task_queue, + ) + + stream = WorkflowStreamClient.create(client, workflow_id) + events: list[StreamEvent] = [] + + async def collect() -> None: + async for item in stream.subscribe( + ["events"], + from_offset=0, + result_type=StreamEvent, + poll_cooldown=timedelta(milliseconds=50), + ): + events.append(item.data) + if len(events) >= 4: + break + + collect_task = asyncio.create_task(collect()) + assert await handle.result() == "Done!\n" + await asyncio.wait_for(collect_task, timeout=10.0) + + history = await handle.fetch_history() + assert get_activities(history) == ["invoke_model_streaming"] + + assert any("messageStart" in e for e in events) + assert any("messageStop" in e for e in events) + + await Replayer( + workflows=[StreamingModelWorkflow], + plugins=[plugin], + ).replay_workflow(history) diff --git a/tests/contrib/strands/test_structured_output.py b/tests/contrib/strands/test_structured_output.py new file mode 100644 index 000000000..18c77c553 --- /dev/null +++ b/tests/contrib/strands/test_structured_output.py @@ -0,0 +1,74 @@ +from datetime import timedelta +from uuid import uuid4 + +from pydantic import BaseModel, Field + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.mock_model import MockModel + + +class PersonInfo(BaseModel): + name: str = Field(description="Name of the person") + age: int = Field(description="Age of the person") + occupation: str = Field(description="Occupation of the person") + + +@workflow.defn +class StructuredOutputWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + structured_output_model=PersonInfo, + ) + + @workflow.run + async def run(self, prompt: str) -> PersonInfo: + result = await self.agent.invoke_async(prompt) + assert isinstance(result.structured_output, PersonInfo) + return result.structured_output + + +async def test_structured_output(client: Client): + task_queue = "test_structured_output" + plugin = StrandsPlugin( + models={ + "mock": lambda: MockModel( + [ + { + "name": "PersonInfo", + "input": { + "name": "John Smith", + "age": 30, + "occupation": "software engineer", + }, + }, + ] + ) + } + ) + + async with Worker( + client, + task_queue=task_queue, + workflows=[StructuredOutputWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + StructuredOutputWorkflow.run, + "John Smith is a 30 year-old software engineer", + id=f"test_structured_output_{uuid4()}", + task_queue=task_queue, + ) + assert await handle.result() == PersonInfo( + name="John Smith", age=30, occupation="software engineer" + ) + + await Replayer( + workflows=[StructuredOutputWorkflow], + plugins=[plugin], + ).replay_workflow(await handle.fetch_history()) diff --git a/tests/contrib/strands/test_tool.py b/tests/contrib/strands/test_tool.py new file mode 100644 index 000000000..39985e2df --- /dev/null +++ b/tests/contrib/strands/test_tool.py @@ -0,0 +1,118 @@ +from datetime import timedelta +from pathlib import Path +from uuid import uuid4 + +from strands import tool +from strands_tools import ( # pyright: ignore[reportMissingTypeStubs] + calculator, + file_read, +) + +from temporalio import activity, workflow +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin, TemporalAgent +from temporalio.contrib.strands.workflow import activity_as_tool +from temporalio.worker import Replayer, Worker +from tests.contrib.strands.common import get_activities +from tests.contrib.strands.mock_model import MockModel + + +@tool +def letter_counter(word: str, letter: str) -> int: + return word.lower().count(letter.lower()) + + +@activity.defn(name="read_file") +async def read_file_activity(path: str) -> str: + result = file_read.file_read( + { + "toolUseId": "read_file", + "name": "file_read", + "input": {"path": path, "mode": "view"}, + } + ) + text = result["content"][0].get("text") + assert text is not None + return text + + +@workflow.defn +class ToolWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + model="mock", + start_to_close_timeout=timedelta(seconds=15), + tools=[ + calculator, + activity_as_tool( + read_file_activity, + start_to_close_timeout=timedelta(seconds=15), + ), + letter_counter, + ], + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) + + +async def test_tool(client: Client, tmp_path: Path): + task_queue = "test_tool" + fixture = tmp_path / "greeting.txt" + fixture.write_text("hello\n") + + plugin = StrandsPlugin( + models={ + "mock": lambda: MockModel( + [ + {"name": "read_file", "input": {"path": str(fixture)}}, + { + "name": "calculator", + "input": {"expression": "3111696 / 74088"}, + }, + { + "name": "letter_counter", + "input": {"word": "strawberry", "letter": "R"}, + }, + "Done!", + ] + ) + } + ) + + async with Worker( + client, + task_queue=task_queue, + workflows=[ToolWorkflow], + activities=[read_file_activity], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + ToolWorkflow.run, + "I have 3 requests:\n" + f"1. Read the file at {fixture}\n" + "2. Calculate 3111696 / 74088\n" + '3. Tell me how many letter R\'s are in the word "strawberry"', + id=f"test_tool_{uuid4()}", + task_queue=task_queue, + ) + assert await handle.result() == "Done!\n" + + history = await handle.fetch_history() + assert get_activities(history) == [ + "invoke_model", + "read_file", + "invoke_model", + # calculator (in-workflow) + "invoke_model", + # letter_counter (in-workflow) + "invoke_model", + ] + + await Replayer( + workflows=[ToolWorkflow], + plugins=[plugin], + ).replay_workflow(history) diff --git a/tests/test_type_errors.py b/tests/test_type_errors.py index d8e6e2afb..2b70d7f63 100644 --- a/tests/test_type_errors.py +++ b/tests/test_type_errors.py @@ -86,7 +86,7 @@ def _test_type_errors( def _has_type_error_assertions(test_file: Path) -> bool: """Check if a file contains any type error assertions.""" - with open(test_file) as f: + with open(test_file, encoding="utf-8") as f: return any(re.search(r"# assert-type-error-\w+:", line) for line in f) @@ -94,7 +94,7 @@ def _get_expected_errors(test_file: Path, type_checker: str) -> dict[int, str]: """Parse expected type errors from comments in a file for the specified type checker.""" expected_errors = {} - with open(test_file) as f: + with open(test_file, encoding="utf-8") as f: lines = zip(itertools.count(1), f) for line_num, line in lines: if match := re.search( diff --git a/uv.lock b/uv.lock index b94b932d5..0055bba40 100644 --- a/uv.lock +++ b/uv.lock @@ -316,6 +316,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, ] +[[package]] +name = "aws-requests-auth" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/b2/455c0bfcbd772dafd4c9e93c4b713e36790abf9ccbca9b8e661968b29798/aws-requests-auth-0.4.3.tar.gz", hash = "sha256:33593372018b960a31dbbe236f89421678b885c35f0b6a7abfae35bb77e069b2", size = 10096, upload-time = "2020-05-27T23:10:34.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/11/5dc8be418e1d54bed15eaf3a7461797e5ebb9e6a34869ad750561f35fa5b/aws_requests_auth-0.4.3-py2.py3-none-any.whl", hash = "sha256:646bc37d62140ea1c709d20148f5d43197e6bd2d63909eb36fa4bb2345759977", size = 6838, upload-time = "2020-05-27T23:10:33.658Z" }, +] + [[package]] name = "aws-sam-translator" version = "1.106.0" @@ -374,6 +386,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/be/6985abb1011fda8a523cfe21ed9629e397d6e06fb5bae99750402b25c95b/bashlex-0.18-py2.py3-none-any.whl", hash = "sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa", size = 69539, upload-time = "2023-01-18T15:21:24.167Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -924,6 +949,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, ] +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -2678,6 +2712,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -3616,6 +3663,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/f5/7a40ff3f62bfe715dad2f633d7f1174ba1a7dd74254c15b2558b3401262a/opentelemetry_instrumentation-0.59b0-py3-none-any.whl", hash = "sha256:44082cc8fe56b0186e87ee8f7c17c327c4c2ce93bdbe86496e600985d74368ee", size = 33020, upload-time = "2025-10-16T08:38:31.463Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/7a/84e97d8992808197006e607ae410c2219bdbbc23d1289ba0c244d3220741/opentelemetry_instrumentation_threading-0.59b0.tar.gz", hash = "sha256:ce5658730b697dcbc0e0d6d13643a69fd8aeb1b32fa8db3bade8ce114c7975f3", size = 8770, upload-time = "2025-10-16T08:40:03.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/50/32d29076aaa1c91983cdd3ca8c6bb4d344830cd7d87a7c0fdc2d98c58509/opentelemetry_instrumentation_threading-0.59b0-py3-none-any.whl", hash = "sha256:76da2fc01fe1dccebff6581080cff9e42ac7b27cc61eb563f3c4435c727e8eca", size = 9313, upload-time = "2025-10-16T08:39:15.876Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.38.0" @@ -3846,6 +3907,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c9/8eed0486f074e9f1ca7f8ce5ad663e65f12fdab344028d658fa1b03d35e0/pathspec-1.1.0-py3-none-any.whl", hash = "sha256:574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", size = 56264, upload-time = "2026-04-23T01:46:20.606Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + [[package]] name = "pkginfo" version = "1.12.1.2" @@ -3873,6 +4032,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -4808,15 +4979,15 @@ wheels = [ [[package]] name = "rich" -version = "15.0.0" +version = "14.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, ] [[package]] @@ -5018,6 +5189,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slack-bolt" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/97/a62dde97e84027b252807f2044bed2edcda2d063a5cb0c535fb2be8d9b5d/slack_bolt-1.28.0.tar.gz", hash = "sha256:bfe367d867e8fb157a057248ebd4ac2d7f43acac6d0700fa31381db1e10f3b0f", size = 130768, upload-time = "2026-04-06T23:24:59.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/a9/697b6a92c728f09d5ef6b8e83dc6c8a87bc6d59499b2933ed067f11b7e30/slack_bolt-1.28.0-py2.py3-none-any.whl", hash = "sha256:738d1ca5e7c7039b6e18103d29267ced6e18c2517053eff18991fdd593acce5c", size = 234819, upload-time = "2026-04-06T23:24:58.278Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/35/fc009118a13187dd9731657c60138e5a7c2dea88681a7f04dc406af5da7d/slack_sdk-3.41.0.tar.gz", hash = "sha256:eb61eb12a65bebeca9cb5d36b3f799e836ed2be21b456d15df2627cfe34076ca", size = 250568, upload-time = "2026-03-12T16:10:11.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/df/2e4be347ff98281b505cc0ccf141408cdd25eb5ca9f3830deb361b2472d3/slack_sdk-3.41.0-py2.py3-none-any.whl", hash = "sha256:bb18dcdfff1413ec448e759cf807ec3324090993d8ab9111c74081623b692a89", size = 313885, upload-time = "2026-03-12T16:10:09.811Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -5036,6 +5228,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -5145,6 +5346,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "strands-agents" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-threading" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/5b/e267a7dab0b4a6d39133c9c0c516f93f33483e29f39e05c03b755f993ef6/strands_agents-1.39.0.tar.gz", hash = "sha256:efff5914323b8b4b472ca3f13c7115a5746935b00bc86dacc40a5d1ab1242817", size = 873258, upload-time = "2026-05-08T13:27:19.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/41/d054b5a5f54175eb4e775d1e408e169439eba6be63e9e8f2e77ff44e38fc/strands_agents-1.39.0-py3-none-any.whl", hash = "sha256:7369dbfc6be29f59483a6183f5aacf0bdd0e7e5973b4b70f8d0e663880d42f79", size = 430272, upload-time = "2026-05-08T13:27:18.088Z" }, +] + +[[package]] +name = "strands-agents-tools" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aws-requests-auth" }, + { name = "botocore" }, + { name = "dill" }, + { name = "markdownify" }, + { name = "pillow" }, + { name = "prompt-toolkit" }, + { name = "pyjwt" }, + { name = "requests" }, + { name = "rich" }, + { name = "slack-bolt" }, + { name = "strands-agents" }, + { name = "sympy" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/32/710a49ffd32b0a232ec1731620ee6105c045e9a77ecee1f3ecaa1a80a6cd/strands_agents_tools-0.5.2.tar.gz", hash = "sha256:96763c8ae75933c5dd327cca87561f573aed720c9c0f3d17fd20835910d11381", size = 483164, upload-time = "2026-04-30T17:08:13.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/ef/fe73b6d25d095784d2e1f6f33419265e796143100fb2f32a6e86f8ae68af/strands_agents_tools-0.5.2-py3-none-any.whl", hash = "sha256:8f85e4cb28d9411e62e1f159aa7e300d3a0f4b1d2b878a7cdfd5d746d9333343", size = 316178, upload-time = "2026-04-30T17:08:11.416Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -5204,6 +5456,9 @@ opentelemetry = [ pydantic = [ { name = "pydantic" }, ] +strands-agents = [ + { name = "strands-agents" }, +] [package.dev-dependencies] dev = [ @@ -5241,6 +5496,8 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "setuptools" }, + { name = "strands-agents" }, + { name = "strands-agents-tools" }, { name = "toml" }, { name = "twine" }, ] @@ -5265,11 +5522,12 @@ requires-dist = [ { name = "protobuf", specifier = ">=3.20,<7.0.0" }, { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.0.0,<3" }, { name = "python-dateutil", marker = "python_full_version < '3.11'", specifier = ">=2.8.2,<3" }, + { name = "strands-agents", marker = "extra == 'strands-agents'", specifier = ">=1.39.0" }, { name = "types-aioboto3", extras = ["s3"], marker = "extra == 'aioboto3'", specifier = ">=10.4.0" }, { name = "types-protobuf", specifier = ">=3.20,<7.0.0" }, { name = "typing-extensions", specifier = ">=4.2.0,<5" }, ] -provides-extras = ["grpc", "opentelemetry", "pydantic", "openai-agents", "google-adk", "langgraph", "langsmith", "lambda-worker-otel", "aioboto3"] +provides-extras = ["grpc", "opentelemetry", "pydantic", "openai-agents", "google-adk", "langgraph", "langsmith", "lambda-worker-otel", "aioboto3", "strands-agents"] [package.metadata.requires-dev] dev = [ @@ -5307,6 +5565,8 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.6,<4" }, { name = "ruff", specifier = ">=0.15.12,<0.16" }, { name = "setuptools", specifier = "<82" }, + { name = "strands-agents", specifier = ">=1.39.0" }, + { name = "strands-agents-tools", specifier = ">=0.5.2" }, { name = "toml", specifier = ">=0.10.2,<0.11" }, { name = "twine", specifier = ">=4.0.1,<5" }, ] @@ -5758,6 +6018,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + [[package]] name = "websockets" version = "15.0.1"