diff --git a/README.md b/README.md index 801fd2ed..feb43d48 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Some examples require extra dependencies. See each sample's directory for specif * [pydantic_converter](pydantic_converter) - Data converter for using Pydantic models. * [schedules](schedules) - Demonstrates a Workflow Execution that occurs according to a schedule. * [sentry](sentry) - Report errors to Sentry. +* [strands_plugin](strands_plugin) - Run Strands Agents as durable Temporal workflows (model calls, tools, MCP, HITL). * [trio_async](trio_async) - Use asyncio Temporal in Trio-based environments. * [updatable_timer](updatable_timer) - A timer that can be updated while sleeping. * [worker_specific_task_queues](worker_specific_task_queues) - Use unique task queues to ensure activities run on specific workers. diff --git a/pyproject.toml b/pyproject.toml index 4c4151a5..bb19cd2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,13 @@ openai-agents = [ ] pydantic-converter = ["pydantic>=2.10.6,<3"] sentry = ["sentry-sdk>=2.13.0"] +strands-agents = [ + "strands-agents>=1.39.0", + "strands-agents-tools>=0.5.2", + "mcp>=1.0.0", + "boto3>=1.34.92,<2", + "temporalio[strands-agents,pydantic]>=1.27.0", +] trio-async = ["trio>=0.28.0,<0.29", "trio-asyncio>=0.15.0,<0.16"] cloud-export-to-parquet = [ "pandas>=2.3.3,<3 ; python_version >= '3.10' and python_version < '4.0'", @@ -71,14 +78,18 @@ cloud-export-to-parquet = [ [tool.uv] constraint-dependencies = [ - # langsmith 0.7.34 changed its aio_to_thread signature; temporalio.contrib.langsmith - # 1.27.2 still patches the older signature, causing workflow task retries to hang CI. - "langsmith<0.7.34", + "langsmith>=0.7.34", # yarl 1.24.0 was published without an sdist and only has cp310 wheels, so it cannot # install on the Python 3.14 CI jobs. "yarl!=1.24.0", ] +# Temporary: the strands extra of temporalio is shipping in an upcoming release. +# Point at the strands branch of sdk-python until it's published. +# Remove this section once `temporalio[strands]` is on PyPI. +[tool.uv.sources] +temporalio = { git = "https://github.com/temporalio/sdk-python.git", branch = "strands" } + [tool.hatch.metadata] allow-direct-references = true @@ -118,6 +129,7 @@ packages = [ "schedules", "sentry", "sleep_for_days", + "strands_plugin", "tests", "trio_async", "updatable_timer", diff --git a/strands_plugin/README.md b/strands_plugin/README.md new file mode 100644 index 00000000..ccf45f1d --- /dev/null +++ b/strands_plugin/README.md @@ -0,0 +1,84 @@ +# Strands Agents Samples + +These samples demonstrate the [Temporal Strands plugin](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/strands), which runs [Strands Agents](https://strandsagents.com/) inside Temporal Workflows. Model invocations, tool calls, and MCP tool calls all execute as Temporal Activities, so you get durable execution, Temporal-managed retries, and timeouts. + +## Samples + +| Sample | Description | +|--------|-------------| +| [hello_world](hello_world) | Minimal `TemporalAgent` invocation. Start here. | +| [tools](tools) | Three tool patterns side by side: in-workflow `@tool`, custom `@activity.defn` wrapped via `activity_as_tool`, and a `strands_tools` tool wrapped as a Temporal activity. | +| [human_in_the_loop](human_in_the_loop) | Pause a tool call on `BeforeToolCallEvent.interrupt()`, resume via Temporal signal. The canonical Strands HITL pattern. | +| [tool_interrupt](tool_interrupt) | Raise `InterruptException` from a Temporal activity to surface a HITL prompt across the activity boundary. Plugin-specific feature. | +| [hooks](hooks) | `HookProvider` with both an in-workflow callback and an `activity_as_hook` callback for I/O. | +| [mcp](mcp) | Connect to an MCP server (`FastMCP` echo) via `TemporalMCPClient`. | +| [structured_output](structured_output) | Pydantic-typed agent output via `structured_output_model`. | +| [streaming](streaming) | Forward model chunks to an external subscriber via `streaming_topic` + `WorkflowStream`. | +| [continue_as_new](continue_as_new) | Chat-style workflow that hands off `agent.messages` when history grows large. | + +## Prerequisites + +1. Install dependencies: + + ```bash + uv sync --group strands + ``` + + > The `strands` extra of `temporalio` is shipping in an upcoming release. Until then, install the SDK from the strands branch: + > + > ```bash + > uv pip install -e ../sdk-python --extra strands-agents --extra pydantic + > ``` + +2. Configure AWS credentials. The samples use the plugin's default `BedrockModel()`, which picks up the standard AWS SDK credential chain. Make sure the credentials grant access to a Bedrock model in your selected region (e.g., `us-west-2`). + + ```bash + export AWS_REGION=us-west-2 + # plus AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY or an SSO profile + ``` + + You can pick a specific model by passing it to `BedrockModel(model_id="...")` in each sample's worker. + +3. Start a [Temporal dev server](https://docs.temporal.io/cli#start-dev-server): + + ```bash + temporal server start-dev + ``` + +## Running a Sample + +Each sample has two scripts. Start the Worker first, then the Workflow starter in a separate terminal: + +```bash +# Terminal 1: start the Worker +uv run strands_plugin//run_worker.py + +# Terminal 2: start the Workflow +uv run strands_plugin//run_workflow.py +``` + +For example, to run the tools sample: + +```bash +# Terminal 1 +uv run strands_plugin/tools/run_worker.py + +# Terminal 2 +uv run strands_plugin/tools/run_workflow.py +``` + +## Key Features Demonstrated + +- **Durable model invocation** — every model call runs in an `invoke_model` activity with configurable timeouts and retries. +- **Three ways to define tools** — pure Strands `@tool`, custom Temporal activities, and ecosystem `strands_tools` wrapped as activities. +- **Human-in-the-loop** — both hook-based (`BeforeToolCallEvent.interrupt()`) and tool-body (`raise InterruptException`) styles. +- **Hook system** — deterministic in-workflow callbacks plus I/O callbacks dispatched via `activity_as_hook`. +- **MCP integration** — connect to MCP servers at worker startup; tool calls dispatched through per-server activities. +- **Structured output** — Pydantic-typed agent results via the plugin's `pydantic_data_converter`. +- **Streaming** — forward model chunks live to external subscribers. +- **Long-lived chats** — hand off `agent.messages` via `continue-as-new` to stay under Temporal's history limit. + +## Related + +- [Temporal Strands plugin docs](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/strands) +- [Strands Agents](https://strandsagents.com/) diff --git a/strands_plugin/__init__.py b/strands_plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/continue_as_new/README.md b/strands_plugin/continue_as_new/README.md new file mode 100644 index 00000000..84e663a5 --- /dev/null +++ b/strands_plugin/continue_as_new/README.md @@ -0,0 +1,30 @@ +# 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; this sample checks it after each turn and hands off to a fresh run with `agent.messages` as input. + +## What This Sample Demonstrates + +- Driving a multi-turn chat with **updates**, so each caller gets the assistant's reply back from the same call +- Seeding a new `TemporalAgent` with prior `agent.messages` +- Using `workflow.info().is_continue_as_new_suggested()` + `workflow.continue_as_new(...)` to keep the workflow alive indefinitely +- Draining in-flight update handlers with `workflow.all_handlers_finished` before continue-as-new + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/continue_as_new/run_worker.py + +# Terminal 2 +uv run strands_plugin/continue_as_new/run_workflow.py +``` + +The starter calls the `turn` update for each user message and prints the assistant's reply, then signals `end_chat`. In a real chatbot, a UI would drive the updates and the workflow would run indefinitely, continuing-as-new whenever history gets large. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `ChatInput`, `ChatWorkflow` with `turn` update, `end_chat` signal, and `messages` query | +| `run_worker.py` | Registers `StrandsPlugin`, starts the worker | +| `run_workflow.py` | Starts the chat, sends a few turns, ends it | diff --git a/strands_plugin/continue_as_new/__init__.py b/strands_plugin/continue_as_new/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/continue_as_new/run_worker.py b/strands_plugin/continue_as_new/run_worker.py new file mode 100644 index 00000000..6cd092e4 --- /dev/null +++ b/strands_plugin/continue_as_new/run_worker.py @@ -0,0 +1,30 @@ +"""Worker for the chat continue-as-new sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.continue_as_new.workflow import ChatWorkflow + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-chat", + workflows=[ChatWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/continue_as_new/run_workflow.py b/strands_plugin/continue_as_new/run_workflow.py new file mode 100644 index 00000000..2a98f421 --- /dev/null +++ b/strands_plugin/continue_as_new/run_workflow.py @@ -0,0 +1,38 @@ +"""Start the chat workflow, send a few turns, then end it.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin + +from strands_plugin.continue_as_new.workflow import ChatInput, ChatWorkflow + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[StrandsPlugin()], + ) + + handle = await client.start_workflow( + ChatWorkflow.run, + ChatInput(), + id="strands-chat", + task_queue="strands-chat", + ) + + for prompt in [ + "Hi! What is durable execution?", + "Give me a one-sentence summary.", + ]: + reply = await handle.execute_update(ChatWorkflow.turn, prompt) + print(f"user: {prompt}") + print(f"assistant: {reply}\n") + + await handle.signal(ChatWorkflow.end_chat) + await handle.result() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/continue_as_new/workflow.py b/strands_plugin/continue_as_new/workflow.py new file mode 100644 index 00000000..a69900f5 --- /dev/null +++ b/strands_plugin/continue_as_new/workflow.py @@ -0,0 +1,63 @@ +"""Chat-style workflow that continues-as-new before history grows too large. + +Each user turn arrives as a Temporal **update**, so the caller gets the +assistant's reply back from the same call. Once Temporal suggests +continue-as-new, the workflow drains any in-flight update handlers and hands +``agent.messages`` off to a fresh run. +""" + +import asyncio +from dataclasses import dataclass, field +from datetime import timedelta + +from strands.types.content import Messages +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent + + +@dataclass +class ChatInput: + messages: Messages = field(default_factory=list) + + +@workflow.defn +class ChatWorkflow: + def __init__(self) -> None: + self._done = False + self._lock = asyncio.Lock() + self._agent: TemporalAgent | None = None + + @workflow.update + async def turn(self, prompt: str) -> str: + # Updates can arrive before ``run`` has constructed the agent. + await workflow.wait_condition(lambda: self._agent is not None) + # Serialize turns so concurrent updates can't interleave on ``agent.messages``. + async with self._lock: + assert self._agent is not None + result = await self._agent.invoke_async(prompt) + return str(result).strip() + + @workflow.signal + def end_chat(self) -> None: + self._done = True + + @workflow.query + def messages(self) -> Messages: + return list(self._agent.messages) if self._agent else [] + + @workflow.run + async def run(self, input: ChatInput) -> None: + self._agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + messages=list(input.messages), + ) + + await workflow.wait_condition( + lambda: self._done or workflow.info().is_continue_as_new_suggested() + ) + + # Let any in-flight ``turn`` updates finish before we exit or hand off. + await workflow.wait_condition(workflow.all_handlers_finished) + + if not self._done: + workflow.continue_as_new(ChatInput(messages=self._agent.messages)) diff --git a/strands_plugin/hello_world/README.md b/strands_plugin/hello_world/README.md new file mode 100644 index 00000000..d1571f9a --- /dev/null +++ b/strands_plugin/hello_world/README.md @@ -0,0 +1,29 @@ +# Hello World + +The simplest Strands + Temporal sample: one `TemporalAgent` invoked once. Every model call runs as an `invoke_model` Temporal activity, so it gets durable retries, timeouts, and crash recovery for free. + +## What This Sample Demonstrates + +- Wiring `StrandsPlugin` onto the client and worker +- Constructing a `TemporalAgent` with no explicit model (defaults to `BedrockModel()`) +- Invoking the agent from a `@workflow.defn` + +## Running the Sample + +Prerequisites: `uv sync --group strands`, AWS credentials with Bedrock access, and a running Temporal dev server (`temporal server start-dev`). + +```bash +# Terminal 1 +uv run strands_plugin/hello_world/run_worker.py + +# Terminal 2 +uv run strands_plugin/hello_world/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `HelloWorldWorkflow` with a single `TemporalAgent` | +| `run_worker.py` | Registers `StrandsPlugin`, starts the worker | +| `run_workflow.py` | Executes the workflow and prints the result | diff --git a/strands_plugin/hello_world/__init__.py b/strands_plugin/hello_world/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/hello_world/run_worker.py b/strands_plugin/hello_world/run_worker.py new file mode 100644 index 00000000..f9ea95b8 --- /dev/null +++ b/strands_plugin/hello_world/run_worker.py @@ -0,0 +1,30 @@ +"""Worker for the hello world sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.hello_world.workflow import HelloWorldWorkflow + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-hello-world", + workflows=[HelloWorldWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/hello_world/run_workflow.py b/strands_plugin/hello_world/run_workflow.py new file mode 100644 index 00000000..6ca18032 --- /dev/null +++ b/strands_plugin/hello_world/run_workflow.py @@ -0,0 +1,25 @@ +"""Start the hello world workflow.""" + +import asyncio +import os + +from temporalio.client import Client + +from strands_plugin.hello_world.workflow import HelloWorldWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + result = await client.execute_workflow( + HelloWorldWorkflow.run, + "Write a haiku about durable execution.", + id="strands-hello-world", + task_queue="strands-hello-world", + ) + + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/hello_world/workflow.py b/strands_plugin/hello_world/workflow.py new file mode 100644 index 00000000..b21c2591 --- /dev/null +++ b/strands_plugin/hello_world/workflow.py @@ -0,0 +1,17 @@ +"""Minimal Temporal + Strands workflow: one agent, one prompt.""" + +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent + + +@workflow.defn +class HelloWorldWorkflow: + 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) diff --git a/strands_plugin/hooks/README.md b/strands_plugin/hooks/README.md new file mode 100644 index 00000000..dcdf4489 --- /dev/null +++ b/strands_plugin/hooks/README.md @@ -0,0 +1,34 @@ +# Hooks + +Strands' [hook system](https://strandsagents.com/) lets you subscribe callbacks to lifecycle events (`BeforeToolCallEvent`, `AfterToolCallEvent`, `BeforeModelCallEvent`, etc.). With the Temporal plugin, hook callbacks run in workflow context — so they must be deterministic — but you can dispatch I/O via `activity_as_hook`. + +This sample wires two callbacks to `AfterToolCallEvent`: + +1. An **in-workflow** callback that appends to per-workflow state. Pure data, deterministic across replay. +2. An **activity-backed** callback (`activity_as_hook(persist_tool_call, ...)`) that calls a Temporal activity for the actual audit write. The `activity_input=` selector pulls a serializable value out of the event. + +## What This Sample Demonstrates + +- Subscribing multiple callbacks to one hook event +- Mixing deterministic in-workflow callbacks with off-workflow activity callbacks +- The `activity_input=` selector for `activity_as_hook` + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/hooks/run_worker.py + +# Terminal 2 +uv run strands_plugin/hooks/run_workflow.py +``` + +The Temporal UI will show one `invoke_model` activity per agent turn plus a `persist_tool_call` activity per tool call. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `echo` tool, `AuditHook` (in-workflow + activity-backed), `HooksWorkflow` | +| `run_worker.py` | Registers `StrandsPlugin` + `persist_tool_call`, starts worker | +| `run_workflow.py` | Executes the workflow and prints the list of fired events | diff --git a/strands_plugin/hooks/__init__.py b/strands_plugin/hooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/hooks/run_worker.py b/strands_plugin/hooks/run_worker.py new file mode 100644 index 00000000..e03a1b9d --- /dev/null +++ b/strands_plugin/hooks/run_worker.py @@ -0,0 +1,31 @@ +"""Worker for the hooks sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.hooks.workflow import HooksWorkflow, persist_tool_call + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-hooks", + workflows=[HooksWorkflow], + activities=[persist_tool_call], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/hooks/run_workflow.py b/strands_plugin/hooks/run_workflow.py new file mode 100644 index 00000000..0959cf3e --- /dev/null +++ b/strands_plugin/hooks/run_workflow.py @@ -0,0 +1,25 @@ +"""Start the hooks workflow.""" + +import asyncio +import os + +from temporalio.client import Client + +from strands_plugin.hooks.workflow import HooksWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + fired = await client.execute_workflow( + HooksWorkflow.run, + "Echo 'hello' once.", + id="strands-hooks", + task_queue="strands-hooks", + ) + + print(f"Tools that fired AfterToolCallEvent: {fired}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/hooks/workflow.py b/strands_plugin/hooks/workflow.py new file mode 100644 index 00000000..6ea853e9 --- /dev/null +++ b/strands_plugin/hooks/workflow.py @@ -0,0 +1,65 @@ +"""Two-hook audit example. + +``AuditHook`` subscribes two callbacks to ``AfterToolCallEvent``: + +* An in-workflow callback that mutates per-workflow state. It runs in workflow + context, so it must be deterministic — pure data manipulation only. +* An ``activity_as_hook`` callback that dispatches to a Temporal activity. Use + this for anything with I/O: writing to an audit log, calling out to a + metrics system, alerting, etc. +""" + +from datetime import timedelta + +from strands import tool +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import AfterToolCallEvent +from temporalio import activity, workflow +from temporalio.contrib.strands import TemporalAgent +from temporalio.contrib.strands.workflow import activity_as_hook + + +@activity.defn +async def persist_tool_call(tool_name: str) -> None: + # In production, write to a database / S3 / your audit pipeline. + activity.logger.info(f"audit: tool {tool_name} completed") + + +@tool +def echo(text: str) -> str: + return text + + +class AuditHook(HookProvider): + def __init__(self) -> None: + self.fired: list[str] = [] + + def register_hooks(self, registry: HookRegistry, **kwargs: object) -> None: + registry.add_callback(AfterToolCallEvent, self._record) + registry.add_callback( + AfterToolCallEvent, + activity_as_hook( + persist_tool_call, + activity_input=lambda event: event.tool_use["name"], + start_to_close_timeout=timedelta(seconds=15), + ), + ) + + def _record(self, event: AfterToolCallEvent) -> None: + self.fired.append(event.tool_use["name"]) + + +@workflow.defn +class HooksWorkflow: + def __init__(self) -> None: + self.hook = AuditHook() + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + 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 diff --git a/strands_plugin/human_in_the_loop/README.md b/strands_plugin/human_in_the_loop/README.md new file mode 100644 index 00000000..84d7ca76 --- /dev/null +++ b/strands_plugin/human_in_the_loop/README.md @@ -0,0 +1,29 @@ +# Human-in-the-loop (Hook-based) + +The canonical Strands HITL pattern. A `BeforeToolCallEvent` hook gates a sensitive tool behind human approval by calling `event.interrupt(...)`. The agent stops with `stop_reason == "interrupt"`; the workflow waits on a Temporal signal for the response, then resumes with `InterruptResponseContent`. + +## What This Sample Demonstrates + +- Using `HookProvider` + `BeforeToolCallEvent` to interrupt before a tool runs +- Pairing Strands' interrupt machinery with Temporal signals + queries +- Resuming the agent with `[{"interruptResponse": ...}]` content + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/human_in_the_loop/run_worker.py + +# Terminal 2 +uv run strands_plugin/human_in_the_loop/run_workflow.py +``` + +The starter script queries the workflow until an approval is pending, prints the reason, then signals `"approve"`. To exercise the denial path, change the signal to anything other than `"approve"`. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `delete_file` tool, `ApprovalHook`, `HumanInTheLoopWorkflow` | +| `run_worker.py` | Registers `StrandsPlugin`, starts the worker | +| `run_workflow.py` | Starts the workflow, polls for the approval, sends the signal | diff --git a/strands_plugin/human_in_the_loop/__init__.py b/strands_plugin/human_in_the_loop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/human_in_the_loop/run_worker.py b/strands_plugin/human_in_the_loop/run_worker.py new file mode 100644 index 00000000..13f4ee3a --- /dev/null +++ b/strands_plugin/human_in_the_loop/run_worker.py @@ -0,0 +1,30 @@ +"""Worker for the hook-based HITL sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.human_in_the_loop.workflow import HumanInTheLoopWorkflow + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-human-in-the-loop", + workflows=[HumanInTheLoopWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/human_in_the_loop/run_workflow.py b/strands_plugin/human_in_the_loop/run_workflow.py new file mode 100644 index 00000000..b106d25c --- /dev/null +++ b/strands_plugin/human_in_the_loop/run_workflow.py @@ -0,0 +1,35 @@ +"""Start the hook-based HITL workflow and send an approval signal.""" + +import asyncio +import os + +from temporalio.client import Client + +from strands_plugin.human_in_the_loop.workflow import HumanInTheLoopWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + handle = await client.start_workflow( + HumanInTheLoopWorkflow.run, + "Please delete /tmp/sensitive.txt", + id="strands-human-in-the-loop", + task_queue="strands-human-in-the-loop", + ) + + # Poll until the agent reaches the approval interrupt. + reason = None + while reason is None: + await asyncio.sleep(0.5) + reason = await handle.query(HumanInTheLoopWorkflow.pending_approval) + print(f"Approval requested: {reason}") + + await handle.signal(HumanInTheLoopWorkflow.approve, "approve") + + result = await handle.result() + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/human_in_the_loop/workflow.py b/strands_plugin/human_in_the_loop/workflow.py new file mode 100644 index 00000000..0b912a2a --- /dev/null +++ b/strands_plugin/human_in_the_loop/workflow.py @@ -0,0 +1,73 @@ +"""Hook-based human-in-the-loop: pause on ``BeforeToolCallEvent.interrupt()``. + +A hook gates the ``delete_file`` tool behind human approval. The agent stops +with ``stop_reason == "interrupt"``; the workflow waits for a signal carrying +the approval response, then resumes the agent with the response. +""" + +from datetime import timedelta +from typing import Optional + +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.contrib.strands import TemporalAgent + + +@tool +def delete_file(path: str) -> str: + return f"deleted {path}" + + +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_file": + return + approval = event.interrupt( + "approval", + reason=f"approve delete of {event.tool_use['input']['path']}?", + ) + if approval != "approve": + event.cancel_tool = "denied" + + +@workflow.defn +class HumanInTheLoopWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[delete_file], + hooks=[ApprovalHook()], + ) + self._approval: Optional[str] = None + self._pending_reason: Optional[str] = None + + @workflow.signal + def approve(self, response: str) -> None: + self._approval = response + + @workflow.query + def pending_approval(self) -> Optional[str]: + return self._pending_reason + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + while result.stop_reason == "interrupt": + interrupts = list(result.interrupts or []) + self._pending_reason = interrupts[0].reason if interrupts else None + await workflow.wait_condition(lambda: self._approval is not None) + response = self._approval + self._approval = None + self._pending_reason = None + responses: list[InterruptResponseContent] = [ + {"interruptResponse": {"interruptId": i.id, "response": response}} + for i in interrupts + ] + result = await self.agent.invoke_async(responses) + return str(result) diff --git a/strands_plugin/interrupt/README.md b/strands_plugin/interrupt/README.md new file mode 100644 index 00000000..900d0c60 --- /dev/null +++ b/strands_plugin/interrupt/README.md @@ -0,0 +1,34 @@ +# Interrupt + +A `@activity.defn`-wrapped tool raises `InterruptException(Interrupt(...))` directly. The plugin's failure converter preserves the `Interrupt` payload across the activity boundary, so the agent stops with `stop_reason == "interrupt"` just like in the hook-based [human_in_the_loop](../human_in_the_loop) sample. + +When to reach for this style instead of a hook: + +- The decision to pause depends on data that's only visible inside the activity (a permissions service, a row in a database, etc.). +- You don't want to load that data into workflow context just to make the call. + +## What This Sample Demonstrates + +- Raising `InterruptException` from a Temporal activity tool +- The plugin's failure converter carrying `Interrupt` across the activity boundary +- Why `StrandsPlugin` must be attached to the **client** (not just the worker) + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/interrupt/run_worker.py + +# Terminal 2 +uv run strands_plugin/interrupt/run_workflow.py +``` + +The starter requests deletion of a "protected" resource. The `delete_thing` activity raises an interrupt for protected names; the starter signals `"approve"` to release it. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `delete_thing` activity that raises `InterruptException`, plus the workflow that handles resumption | +| `run_worker.py` | `StrandsPlugin` on the client + worker, registers the activity | +| `run_workflow.py` | Starts the workflow and signals approval | diff --git a/strands_plugin/interrupt/__init__.py b/strands_plugin/interrupt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/interrupt/run_worker.py b/strands_plugin/interrupt/run_worker.py new file mode 100644 index 00000000..6c85caf0 --- /dev/null +++ b/strands_plugin/interrupt/run_worker.py @@ -0,0 +1,34 @@ +"""Worker for the tool-body interrupt sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.interrupt.workflow import InterruptWorkflow, delete_thing + + +async def main() -> None: + plugin = StrandsPlugin() + # The plugin MUST be on the client so its failure converter is installed. + # Without it, the activity's InterruptException cannot survive serialization + # across the activity boundary as an Interrupt. + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-interrupt", + workflows=[InterruptWorkflow], + activities=[delete_thing], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/interrupt/run_workflow.py b/strands_plugin/interrupt/run_workflow.py new file mode 100644 index 00000000..d24387a5 --- /dev/null +++ b/strands_plugin/interrupt/run_workflow.py @@ -0,0 +1,40 @@ +"""Start the tool-body interrupt workflow.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin + +from strands_plugin.interrupt.workflow import InterruptWorkflow + + +async def main() -> None: + # The starter also goes through the plugin's failure converter so the + # Interrupt payload deserializes cleanly when the workflow result is read. + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[StrandsPlugin()], + ) + + handle = await client.start_workflow( + InterruptWorkflow.run, + "Please delete the 'system' user.", + id="strands-interrupt", + task_queue="strands-interrupt", + ) + + reason = None + while reason is None: + await asyncio.sleep(0.5) + reason = await handle.query(InterruptWorkflow.pending_approval) + print(f"Approval requested: {reason}") + + await handle.signal(InterruptWorkflow.approve, "approve") + + result = await handle.result() + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/interrupt/workflow.py b/strands_plugin/interrupt/workflow.py new file mode 100644 index 00000000..3cdfd472 --- /dev/null +++ b/strands_plugin/interrupt/workflow.py @@ -0,0 +1,83 @@ +"""Tool-body interrupt: ``InterruptException`` raised from a Temporal activity. + +The plugin's failure converter preserves the ``Interrupt`` payload across the +activity boundary, so a Temporal activity can pause the agent for human input +the same way a hook can. + +For this to work, ``StrandsPlugin`` must be attached to the **client** (not +just the worker) so the failure converter is installed on the data converter. +The worker in this sample does exactly that. +""" + +from datetime import timedelta +from typing import Optional + +from strands.interrupt import Interrupt, InterruptException +from strands.types.interrupt import InterruptResponseContent +from temporalio import activity, workflow +from temporalio.contrib.strands import TemporalAgent +from temporalio.contrib.strands.workflow import activity_as_tool + +# Tracks names that have been approved out-of-band. In a real system, this +# would be a row in a policy database; the human reviewer flips a flag during +# the pause, and the activity's next attempt reads the new value and proceeds. +_APPROVED: set[str] = set() + + +@activity.defn +async def delete_thing(name: str) -> str: + if name not in _APPROVED: + # First attempt: mark the name as approved on the way out (simulating + # the human flipping a flag during the interrupt pause) and stop the + # agent. In production this branch would check a real authorization + # service and only raise when the resource is protected. + _APPROVED.add(name) + raise InterruptException( + Interrupt( + id=f"delete:{name}", + name="approval", + reason=f"approve delete of protected resource '{name}'?", + ) + ) + return f"deleted {name}" + + +@workflow.defn +class InterruptWorkflow: + 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=30), + ), + ], + ) + self._approval: Optional[str] = None + self._pending_reason: Optional[str] = None + + @workflow.signal + def approve(self, response: str) -> None: + self._approval = response + + @workflow.query + def pending_approval(self) -> Optional[str]: + return self._pending_reason + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + while result.stop_reason == "interrupt": + interrupts = list(result.interrupts or []) + self._pending_reason = interrupts[0].reason if interrupts else None + await workflow.wait_condition(lambda: self._approval is not None) + response = self._approval + self._approval = None + self._pending_reason = None + responses: list[InterruptResponseContent] = [ + {"interruptResponse": {"interruptId": i.id, "response": response}} + for i in interrupts + ] + result = await self.agent.invoke_async(responses) + return str(result) diff --git a/strands_plugin/mcp/README.md b/strands_plugin/mcp/README.md new file mode 100644 index 00000000..a8328536 --- /dev/null +++ b/strands_plugin/mcp/README.md @@ -0,0 +1,32 @@ +# MCP + +Connect a Strands agent to an [MCP](https://modelcontextprotocol.io/) server through `TemporalMCPClient`. The plugin opens the MCP session at worker startup, enumerates tools, and dispatches each tool call through a dedicated `-call-tool` Temporal activity. + +The included `echo_mcp_server.py` is a one-tool FastMCP server (returns the message it was given). Swap it for any real MCP server (filesystem, Postgres, GitHub, etc.) by changing the factory in `run_worker.py`. + +## What This Sample Demonstrates + +- Registering an MCP server with `StrandsPlugin(mcp_clients=...)` +- Referencing it from a workflow via `TemporalMCPClient(server="...")` +- Running MCP tool calls as durable Temporal activities + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/mcp/run_worker.py + +# Terminal 2 +uv run strands_plugin/mcp/run_workflow.py +``` + +The worker spawns `echo_mcp_server.py` itself; you don't need to start it separately. The Temporal UI will show one `invoke_model` per agent turn plus `echo-call-tool` activities for each MCP tool invocation. + +## Files + +| File | Description | +|------|-------------| +| `echo_mcp_server.py` | FastMCP server exposing a single `echo` tool | +| `workflow.py` | `MCPWorkflow` with `TemporalMCPClient(server="echo")` | +| `run_worker.py` | Spawns the MCP server and starts the worker | +| `run_workflow.py` | Executes the workflow and prints the result | diff --git a/strands_plugin/mcp/__init__.py b/strands_plugin/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/mcp/echo_mcp_server.py b/strands_plugin/mcp/echo_mcp_server.py new file mode 100644 index 00000000..4f3d3531 --- /dev/null +++ b/strands_plugin/mcp/echo_mcp_server.py @@ -0,0 +1,19 @@ +"""Tiny FastMCP server: exposes a single ``echo`` tool over stdio. + +The worker launches this script as a subprocess and connects to it via the +MCP stdio transport. Replace it with any real MCP server in production. +""" + +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/strands_plugin/mcp/run_worker.py b/strands_plugin/mcp/run_worker.py new file mode 100644 index 00000000..17d374ce --- /dev/null +++ b/strands_plugin/mcp/run_worker.py @@ -0,0 +1,52 @@ +"""Worker for the MCP sample. + +The worker launches the ``echo_mcp_server.py`` script as a subprocess at +startup. The plugin opens a stdio MCP session, enumerates tools once, and +caches the schema for the worker's lifetime. +""" + +import asyncio +import os +import sys +from pathlib import Path + +from mcp import StdioServerParameters, stdio_client +from strands.tools.mcp.mcp_client import MCPClient +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.mcp.workflow import MCPWorkflow + +ECHO_SERVER = Path(__file__).parent / "echo_mcp_server.py" + + +def _make_echo_client() -> MCPClient: + return MCPClient( + lambda: stdio_client( + StdioServerParameters( + command=sys.executable, + args=[str(ECHO_SERVER)], + ) + ) + ) + + +async def main() -> None: + plugin = StrandsPlugin(mcp_clients={"echo": _make_echo_client}) + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-mcp", + workflows=[MCPWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/mcp/run_workflow.py b/strands_plugin/mcp/run_workflow.py new file mode 100644 index 00000000..7b9a6765 --- /dev/null +++ b/strands_plugin/mcp/run_workflow.py @@ -0,0 +1,25 @@ +"""Start the MCP workflow.""" + +import asyncio +import os + +from temporalio.client import Client + +from strands_plugin.mcp.workflow import MCPWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + result = await client.execute_workflow( + MCPWorkflow.run, + "Use the echo tool to echo the message 'hello from MCP'.", + id="strands-mcp", + task_queue="strands-mcp", + ) + + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/mcp/workflow.py b/strands_plugin/mcp/workflow.py new file mode 100644 index 00000000..922e2ed2 --- /dev/null +++ b/strands_plugin/mcp/workflow.py @@ -0,0 +1,30 @@ +"""Workflow that uses an MCP server through ``TemporalMCPClient``. + +The plugin connects to each registered MCP server at worker startup and +caches the tool manifest. ``TemporalMCPClient`` on the workflow side is a +pure handle that references the server by name and carries the activity +options for each tool call. +""" + +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent, TemporalMCPClient + + +@workflow.defn +class MCPWorkflow: + 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], + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) diff --git a/strands_plugin/streaming/README.md b/strands_plugin/streaming/README.md new file mode 100644 index 00000000..598dde94 --- /dev/null +++ b/strands_plugin/streaming/README.md @@ -0,0 +1,29 @@ +# Streaming + +Strands' model stream produces a sequence of `StreamEvent`s (token deltas, tool-use chunks, message-stop, etc.). With `TemporalAgent(streaming_topic="events")`, the model activity publishes each chunk onto a workflow-hosted `WorkflowStream`; external subscribers read it via `WorkflowStreamClient`. Chunks are batched on `streaming_batch_interval` (default 100ms) to keep activity overhead low. + +## What This Sample Demonstrates + +- Hosting a `WorkflowStream` on a workflow +- `TemporalAgent(streaming_topic=...)` publishing model events +- An external subscriber reading `StreamEvent`s in real time via `WorkflowStreamClient` + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/streaming/run_worker.py + +# Terminal 2 +uv run strands_plugin/streaming/run_workflow.py +``` + +The starter prints text deltas as they arrive, then the final workflow result. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `StreamingWorkflow` hosting `WorkflowStream` + `TemporalAgent(streaming_topic="events")` | +| `run_worker.py` | Registers `StrandsPlugin`, starts the worker | +| `run_workflow.py` | Starts the workflow and subscribes to the `events` topic | diff --git a/strands_plugin/streaming/__init__.py b/strands_plugin/streaming/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/streaming/run_worker.py b/strands_plugin/streaming/run_worker.py new file mode 100644 index 00000000..d009de1e --- /dev/null +++ b/strands_plugin/streaming/run_worker.py @@ -0,0 +1,30 @@ +"""Worker for the streaming sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.streaming.workflow import StreamingWorkflow + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-streaming", + workflows=[StreamingWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/streaming/run_workflow.py b/strands_plugin/streaming/run_workflow.py new file mode 100644 index 00000000..713fc27f --- /dev/null +++ b/strands_plugin/streaming/run_workflow.py @@ -0,0 +1,49 @@ +"""Start the streaming workflow and consume model events live.""" + +import asyncio +import os +from datetime import timedelta + +from strands.types.streaming import StreamEvent +from temporalio.client import Client +from temporalio.contrib.workflow_streams import WorkflowStreamClient + +from strands_plugin.streaming.workflow import StreamingWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + workflow_id = "strands-streaming" + + handle = await client.start_workflow( + StreamingWorkflow.run, + "Count from 1 to 5, one number per sentence.", + id=workflow_id, + task_queue="strands-streaming", + ) + + async def consume() -> None: + stream = WorkflowStreamClient.create(client, workflow_id) + async for item in stream.subscribe( + ["events"], + from_offset=0, + result_type=StreamEvent, + poll_cooldown=timedelta(milliseconds=50), + ): + event: StreamEvent = item.data + if "contentBlockDelta" in event: + delta = event["contentBlockDelta"].get("delta", {}) + if "text" in delta: + print(delta["text"], end="", flush=True) + elif "messageStop" in event: + print() + return + + consume_task = asyncio.create_task(consume()) + result = await handle.result() + await asyncio.wait_for(consume_task, timeout=10.0) + print(f"Final result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/streaming/workflow.py b/strands_plugin/streaming/workflow.py new file mode 100644 index 00000000..e13b0d06 --- /dev/null +++ b/strands_plugin/streaming/workflow.py @@ -0,0 +1,30 @@ +"""Workflow that streams model chunks to an external subscriber. + +``TemporalAgent(streaming_topic="events")`` publishes each ``StreamEvent`` from +inside the model activity onto a workflow-hosted ``WorkflowStream``. +Subscribers connect via ``WorkflowStreamClient`` and read the topic in real +time. Chunks are batched on the ``streaming_batch_interval`` (default 100ms). +""" + +from datetime import timedelta + +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent +from temporalio.contrib.workflow_streams import WorkflowStream + + +@workflow.defn +class StreamingWorkflow: + def __init__(self) -> None: + # Hosting the stream on the workflow is what makes the topic addressable + # by ``WorkflowStreamClient``. + self.stream = WorkflowStream() + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + streaming_topic="events", + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) diff --git a/strands_plugin/structured_output/README.md b/strands_plugin/structured_output/README.md new file mode 100644 index 00000000..e913f187 --- /dev/null +++ b/strands_plugin/structured_output/README.md @@ -0,0 +1,27 @@ +# Structured Output + +`TemporalAgent` accepts a `structured_output_model=` Pydantic class and returns the agent's response coerced into that model. The plugin installs Temporal's `pydantic_data_converter` by default, so the typed value serializes cleanly across the activity and workflow boundary. + +## What This Sample Demonstrates + +- `TemporalAgent(structured_output_model=PersonInfo, ...)` +- Pulling `result.structured_output` out of the agent result +- The plugin's auto-configured pydantic data converter — no extra setup needed to ship Pydantic types in workflow inputs/outputs + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/structured_output/run_worker.py + +# Terminal 2 +uv run strands_plugin/structured_output/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `PersonInfo` model and `StructuredOutputWorkflow` | +| `run_worker.py` | Registers `StrandsPlugin`, starts the worker | +| `run_workflow.py` | Executes the workflow and prints the typed result | diff --git a/strands_plugin/structured_output/__init__.py b/strands_plugin/structured_output/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/structured_output/run_worker.py b/strands_plugin/structured_output/run_worker.py new file mode 100644 index 00000000..032526f5 --- /dev/null +++ b/strands_plugin/structured_output/run_worker.py @@ -0,0 +1,30 @@ +"""Worker for the structured output sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.structured_output.workflow import StructuredOutputWorkflow + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-structured-output", + workflows=[StructuredOutputWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/structured_output/run_workflow.py b/strands_plugin/structured_output/run_workflow.py new file mode 100644 index 00000000..9116ae6a --- /dev/null +++ b/strands_plugin/structured_output/run_workflow.py @@ -0,0 +1,30 @@ +"""Start the structured output workflow.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin + +from strands_plugin.structured_output.workflow import StructuredOutputWorkflow + + +async def main() -> None: + # Plugin on the client so the pydantic data converter is installed. + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[StrandsPlugin()], + ) + + person = await client.execute_workflow( + StructuredOutputWorkflow.run, + "John Smith is a 30 year-old software engineer.", + id="strands-structured-output", + task_queue="strands-structured-output", + ) + + print(f"name={person.name} age={person.age} occupation={person.occupation}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/structured_output/workflow.py b/strands_plugin/structured_output/workflow.py new file mode 100644 index 00000000..912345a2 --- /dev/null +++ b/strands_plugin/structured_output/workflow.py @@ -0,0 +1,34 @@ +"""Structured output: agent returns a typed Pydantic model. + +``TemporalAgent(structured_output_model=PersonInfo)`` makes Strands coerce the +model's final response into an instance of ``PersonInfo``. The plugin installs +``pydantic_data_converter`` by default, so the typed value flows back across +the activity/workflow boundary without extra wiring. +""" + +from datetime import timedelta + +from pydantic import BaseModel, Field +from temporalio import workflow +from temporalio.contrib.strands import TemporalAgent + + +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( + 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) + assert isinstance(result.structured_output, PersonInfo) + return result.structured_output diff --git a/strands_plugin/tools/README.md b/strands_plugin/tools/README.md new file mode 100644 index 00000000..400eff2c --- /dev/null +++ b/strands_plugin/tools/README.md @@ -0,0 +1,35 @@ +# Tools + +Three Strands tool patterns wired into one `TemporalAgent`: + +| Pattern | When to use it | +|---------|----------------| +| `@tool` from `strands` | Pure, deterministic logic with no I/O. Runs in workflow context. | +| `@activity.defn` wrapped via `activity_as_tool` | Anything with I/O, non-determinism, or significant runtime — gets durable retries and timeouts. | +| `strands_tools.` wrapped in an `@activity.defn` | Reuse Strands ecosystem tools (`environment`, `http_request`, `python_repl`, …) while keeping workflow code deterministic. | + +A single prompt exercises all three. The resulting Temporal history shows an `invoke_model` for each model turn, plus `fetch_weather` and `environment` activity calls; the `letter_counter` call runs in-workflow and doesn't show up as an activity. + +## What This Sample Demonstrates + +- Three coexisting tool surfaces on one agent +- `workflow.activity_as_tool` carrying per-tool activity options (timeouts) +- Wrapping `strands_tools` tools so runtime host access happens in an activity + +## Running the Sample + +```bash +# Terminal 1 +uv run strands_plugin/tools/run_worker.py + +# Terminal 2 +uv run strands_plugin/tools/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | The three tools, the `TemporalAgent`, and `ToolsWorkflow` | +| `run_worker.py` | Registers `StrandsPlugin` + the two activities, starts the worker | +| `run_workflow.py` | Executes the workflow and prints the result | diff --git a/strands_plugin/tools/__init__.py b/strands_plugin/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/strands_plugin/tools/run_worker.py b/strands_plugin/tools/run_worker.py new file mode 100644 index 00000000..63345dc9 --- /dev/null +++ b/strands_plugin/tools/run_worker.py @@ -0,0 +1,35 @@ +"""Worker for the tools sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.tools.workflow import ( + ToolsWorkflow, + environment_activity, + fetch_weather, +) + + +async def main() -> None: + plugin = StrandsPlugin() + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[plugin], + ) + + worker = Worker( + client, + task_queue="strands-tools", + workflows=[ToolsWorkflow], + activities=[fetch_weather, environment_activity], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/tools/run_workflow.py b/strands_plugin/tools/run_workflow.py new file mode 100644 index 00000000..04256c73 --- /dev/null +++ b/strands_plugin/tools/run_workflow.py @@ -0,0 +1,30 @@ +"""Start the tools workflow.""" + +import asyncio +import os + +from temporalio.client import Client + +from strands_plugin.tools.workflow import ToolsWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + result = await client.execute_workflow( + ToolsWorkflow.run, + ( + "Please do three things:\n" + "1. Count the letter R's in 'strawberry'.\n" + "2. Fetch the weather in San Francisco.\n" + "3. Validate that the PATH environment variable exists." + ), + id="strands-tools", + task_queue="strands-tools", + ) + + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/strands_plugin/tools/workflow.py b/strands_plugin/tools/workflow.py new file mode 100644 index 00000000..898413bb --- /dev/null +++ b/strands_plugin/tools/workflow.py @@ -0,0 +1,93 @@ +"""Three tool patterns wired into one Strands agent. + +1. ``@tool letter_counter`` — pure Strands tool, runs deterministically in + workflow context. +2. ``@activity.defn fetch_weather`` — custom Temporal activity wrapped with + ``activity_as_tool``; suitable for I/O and non-deterministic work. +3. ``environment`` (from ``strands_tools``) — third-party Strands tool wrapped + in a thin ``@activity.defn`` so its runtime host access runs in an activity. +""" + +from datetime import timedelta +from typing import Any, cast + +from strands import tool +from strands.types.tools import ToolUse +from strands_tools import environment # type: ignore[import-untyped] +from temporalio import activity, workflow +from temporalio.contrib.strands import TemporalAgent +from temporalio.contrib.strands.workflow import activity_as_tool + + +@tool +def letter_counter(word: str, letter: str) -> int: + """Count how many times ``letter`` appears in ``word`` (case-insensitive).""" + return word.lower().count(letter.lower()) + + +@activity.defn +async def fetch_weather(city: str) -> dict: + """Stub weather lookup — replace with a real HTTP call in production.""" + return { + "city": city, + "temperature_f": 72, + "conditions": "sunny", + } + + +@activity.defn(name="environment") +async def environment_activity( + action: str, + name: str | None = None, + value: str | None = None, + prefix: str | None = None, + masked: bool | None = None, +) -> dict: + """Run ``strands_tools.environment`` inside an activity. + + Environment variables are runtime host state. Wrapping this tool in an + activity keeps that non-deterministic access out of workflow replay. + """ + tool_input: dict[str, Any] = {"action": action} + if name is not None: + tool_input["name"] = name + if value is not None: + tool_input["value"] = value + if prefix is not None: + tool_input["prefix"] = prefix + if masked is not None: + tool_input["masked"] = masked + + tool_use = cast( + ToolUse, + { + "toolUseId": "environment", + "name": "environment", + "input": tool_input, + }, + ) + return environment.environment(tool_use) + + +@workflow.defn +class ToolsWorkflow: + def __init__(self) -> None: + self.agent = TemporalAgent( + start_to_close_timeout=timedelta(seconds=60), + tools=[ + letter_counter, + activity_as_tool( + fetch_weather, + start_to_close_timeout=timedelta(seconds=30), + ), + activity_as_tool( + environment_activity, + start_to_close_timeout=timedelta(seconds=30), + ), + ], + ) + + @workflow.run + async def run(self, prompt: str) -> str: + result = await self.agent.invoke_async(prompt) + return str(result) diff --git a/tests/strands_plugin/__init__.py b/tests/strands_plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/strands_plugin/_mock_model.py b/tests/strands_plugin/_mock_model.py new file mode 100644 index 00000000..9f97bedb --- /dev/null +++ b/tests/strands_plugin/_mock_model.py @@ -0,0 +1,69 @@ +"""Scripted Strands model for sample tests. + +Vendored from the SDK's ``tests/contrib/strands/mock_model.py``. Each entry in +``responses`` drives one ``stream()`` call: a ``str`` yields a text turn, a +``dict`` of ``{"name", "input"}`` yields a tool-use turn. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterable +from typing import Any + +import temporalio.contrib.strands._plugin as _plugin_module +from strands.models import Model +from strands.types.streaming import StreamEvent + + +class MockModel(Model): + 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"}} + + +def patch_bedrock(monkeypatch: Any, responses: list[Any]) -> None: + """Make the plugin's implicit BedrockModel factory return a scripted MockModel.""" + monkeypatch.setattr( + _plugin_module, "BedrockModel", lambda *a, **kw: MockModel(responses) + ) diff --git a/tests/strands_plugin/continue_as_new_test.py b/tests/strands_plugin/continue_as_new_test.py new file mode 100644 index 00000000..cb85644f --- /dev/null +++ b/tests/strands_plugin/continue_as_new_test.py @@ -0,0 +1,47 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.continue_as_new.workflow import ChatInput, ChatWorkflow +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_continue_as_new_chat( + client: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + patch_bedrock(monkeypatch, ["First reply.", "Second reply."]) + + task_queue = f"strands-chat-{uuid.uuid4()}" + plugin = StrandsPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[ChatWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + ChatWorkflow.run, + ChatInput(), + id=f"strands-chat-{uuid.uuid4()}", + task_queue=task_queue, + ) + reply1 = await handle.execute_update(ChatWorkflow.turn, "Hello") + reply2 = await handle.execute_update(ChatWorkflow.turn, "How are you?") + + assert reply1 == "First reply." + assert reply2 == "Second reply." + + messages = await handle.query(ChatWorkflow.messages) + await handle.signal(ChatWorkflow.end_chat) + await handle.result() + + # 2 user turns + 2 assistant replies + assert len(messages) == 4 diff --git a/tests/strands_plugin/hello_world_test.py b/tests/strands_plugin/hello_world_test.py new file mode 100644 index 00000000..5110ec75 --- /dev/null +++ b/tests/strands_plugin/hello_world_test.py @@ -0,0 +1,35 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.hello_world.workflow import HelloWorldWorkflow +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_hello_world(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + patch_bedrock(monkeypatch, ["A haiku, for you."]) + + task_queue = f"strands-hello-world-{uuid.uuid4()}" + plugin = StrandsPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[HelloWorldWorkflow], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + HelloWorldWorkflow.run, + "Write a haiku.", + id=f"strands-hello-world-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert result == "A haiku, for you.\n" diff --git a/tests/strands_plugin/hooks_test.py b/tests/strands_plugin/hooks_test.py new file mode 100644 index 00000000..72d9bff5 --- /dev/null +++ b/tests/strands_plugin/hooks_test.py @@ -0,0 +1,42 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.hooks.workflow import HooksWorkflow, persist_tool_call +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_hooks(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + patch_bedrock( + monkeypatch, + [ + {"name": "echo", "input": {"text": "hi"}}, + "Done!", + ], + ) + + task_queue = f"strands-hooks-{uuid.uuid4()}" + plugin = StrandsPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[HooksWorkflow], + activities=[persist_tool_call], + max_cached_workflows=0, + ): + fired = await client.execute_workflow( + HooksWorkflow.run, + "Echo hi.", + id=f"strands-hooks-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert fired == ["echo"] diff --git a/tests/strands_plugin/human_in_the_loop_test.py b/tests/strands_plugin/human_in_the_loop_test.py new file mode 100644 index 00000000..53cd5e84 --- /dev/null +++ b/tests/strands_plugin/human_in_the_loop_test.py @@ -0,0 +1,45 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.human_in_the_loop.workflow import HumanInTheLoopWorkflow +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_human_in_the_loop_approve( + client: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + patch_bedrock( + monkeypatch, + [ + {"name": "delete_file", "input": {"path": "/tmp/sensitive.txt"}}, + "Done!", + ], + ) + + task_queue = f"strands-hitl-{uuid.uuid4()}" + plugin = StrandsPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[HumanInTheLoopWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + HumanInTheLoopWorkflow.run, + "Delete /tmp/sensitive.txt", + id=f"strands-hitl-{uuid.uuid4()}", + task_queue=task_queue, + ) + await handle.signal(HumanInTheLoopWorkflow.approve, "approve") + result = await handle.result() + + assert result == "Done!\n" diff --git a/tests/strands_plugin/interrupt_test.py b/tests/strands_plugin/interrupt_test.py new file mode 100644 index 00000000..b6d016b2 --- /dev/null +++ b/tests/strands_plugin/interrupt_test.py @@ -0,0 +1,45 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.interrupt.workflow import InterruptWorkflow, delete_thing +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_interrupt(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + patch_bedrock( + monkeypatch, + [ + {"name": "delete_thing", "input": {"name": "system"}}, + {"name": "delete_thing", "input": {"name": "system"}}, + "Done!", + ], + ) + + task_queue = f"strands-interrupt-{uuid.uuid4()}" + plugin = StrandsPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[InterruptWorkflow], + activities=[delete_thing], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + InterruptWorkflow.run, + "Delete the system user.", + id=f"strands-interrupt-{uuid.uuid4()}", + task_queue=task_queue, + ) + await handle.signal(InterruptWorkflow.approve, "approve") + result = await handle.result() + + assert result == "Done!\n" diff --git a/tests/strands_plugin/mcp_test.py b/tests/strands_plugin/mcp_test.py new file mode 100644 index 00000000..9480f81d --- /dev/null +++ b/tests/strands_plugin/mcp_test.py @@ -0,0 +1,42 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.mcp.run_worker import _make_echo_client +from strands_plugin.mcp.workflow import MCPWorkflow +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_mcp(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + patch_bedrock( + monkeypatch, + [ + {"name": "echo", "input": {"message": "hello from MCP"}}, + "Done!", + ], + ) + + task_queue = f"strands-mcp-{uuid.uuid4()}" + plugin = StrandsPlugin(mcp_clients={"echo": _make_echo_client}) + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[MCPWorkflow], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + MCPWorkflow.run, + "Echo hello from MCP.", + id=f"strands-mcp-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert result == "Done!\n" diff --git a/tests/strands_plugin/streaming_test.py b/tests/strands_plugin/streaming_test.py new file mode 100644 index 00000000..1a57aefd --- /dev/null +++ b/tests/strands_plugin/streaming_test.py @@ -0,0 +1,59 @@ +import asyncio +import uuid +from datetime import timedelta + +import pytest +from strands.types.streaming import StreamEvent +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.contrib.workflow_streams import WorkflowStreamClient +from temporalio.worker import Worker + +from strands_plugin.streaming.workflow import StreamingWorkflow +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_streaming(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + patch_bedrock(monkeypatch, ["Done!"]) + + task_queue = f"strands-streaming-{uuid.uuid4()}" + plugin = StrandsPlugin() + workflow_id = f"strands-streaming-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[StreamingWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + StreamingWorkflow.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: + return + + collect_task = asyncio.create_task(collect()) + assert await handle.result() == "Done!\n" + await asyncio.wait_for(collect_task, timeout=10.0) + + assert any("messageStart" in e for e in events) + assert any("messageStop" in e for e in events) diff --git a/tests/strands_plugin/structured_output_test.py b/tests/strands_plugin/structured_output_test.py new file mode 100644 index 00000000..fba1f10c --- /dev/null +++ b/tests/strands_plugin/structured_output_test.py @@ -0,0 +1,54 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.structured_output.workflow import ( + PersonInfo, + StructuredOutputWorkflow, +) +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_structured_output( + client: Client, monkeypatch: pytest.MonkeyPatch +) -> None: + patch_bedrock( + monkeypatch, + [ + { + "name": "PersonInfo", + "input": { + "name": "John Smith", + "age": 30, + "occupation": "software engineer", + }, + }, + ], + ) + + task_queue = f"strands-structured-output-{uuid.uuid4()}" + plugin = StrandsPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[StructuredOutputWorkflow], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + StructuredOutputWorkflow.run, + "John Smith is a 30 year-old software engineer.", + id=f"strands-structured-output-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert result == PersonInfo( + name="John Smith", age=30, occupation="software engineer" + ) diff --git a/tests/strands_plugin/tools_test.py b/tests/strands_plugin/tools_test.py new file mode 100644 index 00000000..5aa36f88 --- /dev/null +++ b/tests/strands_plugin/tools_test.py @@ -0,0 +1,51 @@ +import uuid + +import pytest +from temporalio.client import Client +from temporalio.contrib.strands import StrandsPlugin +from temporalio.worker import Worker + +from strands_plugin.tools.workflow import ( + ToolsWorkflow, + environment_activity, + fetch_weather, +) +from tests.strands_plugin._mock_model import patch_bedrock + + +async def test_tools(client: Client, monkeypatch: pytest.MonkeyPatch) -> None: + patch_bedrock( + monkeypatch, + [ + { + "name": "letter_counter", + "input": {"word": "strawberry", "letter": "R"}, + }, + {"name": "fetch_weather", "input": {"city": "San Francisco"}}, + {"name": "environment", "input": {"action": "validate", "name": "PATH"}}, + "Done!", + ], + ) + + task_queue = f"strands-tools-{uuid.uuid4()}" + plugin = StrandsPlugin() + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[ToolsWorkflow], + activities=[fetch_weather, environment_activity], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + ToolsWorkflow.run, + "Use all three tools.", + id=f"strands-tools-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert result == "Done!\n" diff --git a/uv.lock b/uv.lock index f172ac54..5ba853e2 100644 --- a/uv.lock +++ b/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [manifest] constraints = [ - { name = "langsmith", specifier = "<0.7.34" }, + { name = "langsmith", specifier = ">=0.7.34" }, { name = "yarl", specifier = "!=1.24.0" }, ] @@ -290,6 +290,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[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" @@ -318,6 +330,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/c3/f30a7a63e664acc7c2545ca0491b6ce8264536e0e5cad3965f1d1b91e960/aws_xray_sdk-2.15.0-py2.py3-none-any.whl", hash = "sha256:422d62ad7d52e373eebb90b642eb1bb24657afe03b22a8df4a8b2e5108e278a3", size = 103228, upload-time = "2025-10-29T21:00:24.12Z" }, ] +[[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" @@ -623,6 +648,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, ] +[[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" @@ -1580,7 +1614,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.7.33" +version = "0.7.38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -1593,9 +1627,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/75/1ee27b3510bf5b1b569b9695c9466c256caab45885bd569c0c67720236ad/langsmith-0.7.33.tar.gz", hash = "sha256:fa2d81ad6e8374a81fda9291894f6fcae714e55fbf11a0b07578e3cd4b1ea384", size = 1186298, upload-time = "2026-04-20T16:17:54.583Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c9/b3e54cfcb480876dfe33ecfdd64feeb621a86d9e6f4a6b9eb46851807018/langsmith-0.7.38.tar.gz", hash = "sha256:0db529b768d66c45f22fe959a0af7151342704fefafdecf3c60b14097c14fdb1", size = 4431914, upload-time = "2026-04-29T00:21:42.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/76/53033db34ffccd25d62c32b23b9468f7228b455da6976e1c420ae31555c4/langsmith-0.7.33-py3-none-any.whl", hash = "sha256:5b535b991d52d3b664ebb8dc6f95afcf8d0acb42e062ac45a54a6a4820139f20", size = 378981, upload-time = "2026-04-20T16:17:52.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/bc/a19d0a6d5575c637796675831dbef3555568e84d913f14ec579f92162ffa/langsmith-0.7.38-py3-none-any.whl", hash = "sha256:9c400ad508c0e4edc37bd55987047c6b8aac36ddd55f6096e3806f4d6a100618", size = 392310, upload-time = "2026-04-29T00:21:40.534Z" }, ] [[package]] @@ -1763,6 +1797,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[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" @@ -2332,6 +2379,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/e9/308c4c03b536005a1443bee0d9f06de38aad8b94f59f58ac688ead7a8cf9/opentelemetry_exporter_otlp_proto_grpc-1.42.0-py3-none-any.whl", hash = "sha256:5d6d1691586f2e656fd14187f2f2f5fa06e94834e1acdce71edcbbe35730b31d", size = 19614, upload-time = "2026-05-19T09:46:12.331Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.63b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/2d/322d464f4105966fb8555f871a84f43e821ce9aaf64ecae9586e9691c6a2/opentelemetry_instrumentation-0.63b0.tar.gz", hash = "sha256:80a339ef030a8d0fd1962375a9801dd31954e5063d74c00bc3d4e6581f43bab1", size = 41083, upload-time = "2026-05-19T09:47:06.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/45/a38e74da3f1b5c82c97289da91d978caa04321877f0ab170fc620a0753f2/opentelemetry_instrumentation-0.63b0-py3-none-any.whl", hash = "sha256:984b18763b652a881ac5a596098d89923f74cf53a658c2dde660387e018147ca", size = 35574, upload-time = "2026-05-19T09:46:07.257Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.63b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/5a/6c9d86f498b1986be04d0e7b88488605e380cd30bc331f6e46a11e143f8d/opentelemetry_instrumentation_threading-0.63b0.tar.gz", hash = "sha256:e3d03f4de179b52b9209b963f4cbaf6267235bc4c3aba309418c3e26bf80e85f", size = 9076, upload-time = "2026-05-19T09:47:36.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/98/5d80afa96475d974f88ec5c9c4b23eed0b5124b124ce17a8d7ec04b07a68/opentelemetry_instrumentation_threading-0.63b0-py3-none-any.whl", hash = "sha256:91a33db33197ec9dfd8902c7b2b7114954774a06cbe37dfabd1c5e837d26457e", size = 8486, upload-time = "2026-05-19T09:46:52.935Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.42.0" @@ -2617,6 +2693,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[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 = "pluggy" version = "1.6.0" @@ -2640,6 +2814,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/01/a9d6ea30351919298d3dc237172ae6a60ce0224ecaaee289b671ab979c13/poethepoet-0.46.0-py3-none-any.whl", hash = "sha256:dc6d770a14792d124abac9066c5a707876027d1878ac9ca26cf57e9b2a96dc89", size = 150581, upload-time = "2026-05-15T15:52:01.118Z" }, ] +[[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.5.2" @@ -3420,15 +3606,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]] @@ -3630,6 +3816,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.42.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/00/16258bfa547559b2c936b50c882b4f0a36ebf6b69639eb763d8fa5e8d6cb/slack_sdk-3.42.0.tar.gz", hash = "sha256:873db9e1f632ac650ffdbf9d8ba825f3e9e7e576a1e4f9604ccb2a15b3727e3d", size = 252136, upload-time = "2026-05-18T17:50:44.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -3648,6 +3855,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[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 = "sse-starlette" version = "3.4.4" @@ -3674,6 +3890,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "strands-agents" +version = "1.41.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/f0/2f/315889aa7ec662d72db1743df253bbc463a64715c77f0a244f6483bcdc1d/strands_agents-1.41.0.tar.gz", hash = "sha256:e91837ab7f3b90fc2200426ea8756e0cd6a0f665a488c72d03ab835788f6adcf", size = 886067, upload-time = "2026-05-21T17:50:09.682Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/4a/7855503e8e270324e1092745712b78e7de50fc64340ed9cf4eea0cd1c57c/strands_agents-1.41.0-py3-none-any.whl", hash = "sha256:1792e488812ce9fd8a9e815cf37b86a5de33f5b9cae5b1e54608129712cea142", size = 438042, upload-time = "2026-05-21T17:50:07.371Z" }, +] + +[[package]] +name = "strands-agents-tools" +version = "0.6.0" +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/4b/0c/5266b0f12a6809f5f33d2fc4811ab095b7f2907138695e2443c1e0754eef/strands_agents_tools-0.6.0.tar.gz", hash = "sha256:6b5ae6ddf17435079bd5efeda23b4297eea93313527ef87d19fcb0fe1d49d001", size = 490145, upload-time = "2026-05-21T18:32:20.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/d0/981a9e766c73761c3a9404c4c8ce0bd2b13d9d9697a57c688c4788040ebc/strands_agents_tools-0.6.0-py3-none-any.whl", hash = "sha256:1ce0249a1761cecc61e14d81d63d6a36e9068a1069c4d3354ca68e0d7c26a643", size = 319407, upload-time = "2026-05-21T18:32:18.472Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -3689,7 +3956,7 @@ wheels = [ [[package]] name = "temporalio" version = "1.27.2" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/temporalio/sdk-python.git?branch=strands#c84320c66a55b9e3ad5284342426f302046c2272" } dependencies = [ { name = "nexus-rpc" }, { name = "protobuf" }, @@ -3697,14 +3964,6 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/62/2bc1a9ad29382a3a99f088907ef2024a94420cfef340be1b33026c632828/temporalio-1.27.2.tar.gz", hash = "sha256:633bf2379492f3db1e887d1e64fdac00d9c2ddc3e9382b831d5af68256912e92", size = 2503041, upload-time = "2026-05-14T02:17:57.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/85/9da14f9fbdfae95435d29353bb1c55891581ad6b23c86ca56e72d83035ed/temporalio-1.27.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:860f706380faafec8f183f9194d0883c8033a4211c5d19c2c962c45b06cf99e9", size = 14602829, upload-time = "2026-05-14T02:17:45.624Z" }, - { url = "https://files.pythonhosted.org/packages/24/51/b7437991e71eea082dc53222da11f064974917cd59063ba57e13e5895fbc/temporalio-1.27.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a8dc0c680e351f3132809861888d8326dbd5030dd4e570663597e7d4768d9502", size = 13997680, upload-time = "2026-05-14T02:17:53.968Z" }, - { url = "https://files.pythonhosted.org/packages/8c/5d/358065040e6f0cedbf669acd333622999eec737ff868ca7829d727b77746/temporalio-1.27.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805f3de4d193dec52e040e41dbfc9ab44be0206d2e81142ceefaf7b7208058d1", size = 14252199, upload-time = "2026-05-14T02:17:36.972Z" }, - { url = "https://files.pythonhosted.org/packages/72/8a/85d2eab07c3e23fc1124203e76857c69ab9b22d8ccebad0835e294edb754/temporalio-1.27.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bc996cb501b8a918f50037ccee6facb05bb70984acada4c2a3e01f5e7957a38", size = 14779945, upload-time = "2026-05-14T02:18:05.513Z" }, - { url = "https://files.pythonhosted.org/packages/67/81/c9b08609e2a92ecf62c97c59cabfa0608337c8d5cc9941eed5d9a7778840/temporalio-1.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:62a84ae9a60c17932971e4ca3b0f3cd6f32f173b8183e759989376503fb95af6", size = 14981897, upload-time = "2026-05-14T02:17:27.333Z" }, -] [package.optional-dependencies] langgraph = [ @@ -3724,6 +3983,9 @@ opentelemetry = [ pydantic = [ { name = "pydantic" }, ] +strands-agents = [ + { name = "strands-agents" }, +] [[package]] name = "temporalio-samples" @@ -3806,6 +4068,13 @@ pydantic-converter = [ sentry = [ { name = "sentry-sdk" }, ] +strands-agents = [ + { name = "boto3" }, + { name = "mcp" }, + { name = "strands-agents" }, + { name = "strands-agents-tools" }, + { name = "temporalio", extra = ["pydantic", "strands-agents"] }, +] trio-async = [ { name = "trio" }, { name = "trio-asyncio" }, @@ -3814,7 +4083,7 @@ trio-async = [ [package.metadata] requires-dist = [ { name = "protobuf", specifier = ">=5.29.6,<6" }, - { name = "temporalio", specifier = ">=1.27.2,<2" }, + { name = "temporalio", git = "https://github.com/temporalio/sdk-python.git?branch=strands" }, ] [package.metadata.requires-dev] @@ -3858,25 +4127,32 @@ langgraph = [ { name = "langchain", specifier = ">=0.3.0" }, { name = "langchain-anthropic", specifier = ">=0.3.0" }, { name = "langgraph", specifier = ">=1.1.3" }, - { name = "temporalio", extras = ["langgraph", "langsmith"], specifier = ">=1.27.0" }, + { name = "temporalio", extras = ["langgraph", "langsmith"], git = "https://github.com/temporalio/sdk-python.git?branch=strands" }, ] langsmith-tracing = [ { name = "langsmith", specifier = ">=0.7.0" }, { name = "openai", specifier = ">=1.4.0" }, - { name = "temporalio", extras = ["pydantic", "langsmith"], specifier = ">=1.27.0" }, + { name = "temporalio", extras = ["pydantic", "langsmith"], git = "https://github.com/temporalio/sdk-python.git?branch=strands" }, ] nexus = [{ name = "nexus-rpc", specifier = ">=1.1.0,<2" }] open-telemetry = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "temporalio", extras = ["opentelemetry"] }, + { name = "temporalio", extras = ["opentelemetry"], git = "https://github.com/temporalio/sdk-python.git?branch=strands" }, ] openai-agents = [ { name = "openai-agents", extras = ["litellm"], specifier = ">=0.14.1" }, { name = "requests", specifier = ">=2.32.0,<3" }, - { name = "temporalio", extras = ["openai-agents", "opentelemetry"], specifier = ">=1.27.0" }, + { name = "temporalio", extras = ["openai-agents", "opentelemetry"], git = "https://github.com/temporalio/sdk-python.git?branch=strands" }, ] pydantic-converter = [{ name = "pydantic", specifier = ">=2.10.6,<3" }] sentry = [{ name = "sentry-sdk", specifier = ">=2.13.0" }] +strands-agents = [ + { name = "boto3", specifier = ">=1.34.92,<2" }, + { name = "mcp", specifier = ">=1.0.0" }, + { name = "strands-agents", specifier = ">=1.39.0" }, + { name = "strands-agents-tools", specifier = ">=0.5.2" }, + { name = "temporalio", extras = ["strands-agents", "pydantic"], git = "https://github.com/temporalio/sdk-python.git?branch=strands" }, +] trio-async = [ { name = "trio", specifier = ">=0.28.0,<0.29" }, { name = "trio-asyncio", specifier = ">=0.15.0,<0.16" }, @@ -4302,6 +4578,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { 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 = "16.0"