diff --git a/README.md b/README.md index d39428dc..90e64004 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Some examples require extra dependencies. See each sample's directory for specif * [custom_converter](custom_converter) - Use a custom payload converter to handle custom types. * [custom_decorator](custom_decorator) - Custom decorator to auto-heartbeat a long-running activity. * [custom_metric](custom_metric) - Custom metric to record the workflow type in the activity schedule to start latency. +* [deepagents_plugin](deepagents_plugin) - Make LangChain Deep Agents durable: each LLM/tool/backend call becomes a Temporal Activity while the agent loop replays in the Workflow. * [dsl](dsl) - DSL workflow that executes steps defined in a YAML file. * [eager_wf_start](eager_wf_start) - Run a workflow using Eager Workflow Start * [encryption](encryption) - Apply end-to-end encryption for all input/output. diff --git a/deepagents_plugin/README.md b/deepagents_plugin/README.md new file mode 100644 index 00000000..8e90f623 --- /dev/null +++ b/deepagents_plugin/README.md @@ -0,0 +1,126 @@ +# Deep Agents Samples + +These samples demonstrate the [Temporal Deep Agents plugin](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/deepagents), +which makes [LangChain Deep Agents](https://github.com/langchain-ai/deepagents) +durable. Build your agent with `create_deep_agent(...)` inside a +`@workflow.defn` and add `DeepAgentsPlugin()` to your client — each LLM call and +each I/O tool/backend operation becomes a Temporal Activity, while the agent's +control loop runs (and deterministically replays) inside the Workflow. + +> **Experimental.** The `temporalio.contrib.deepagents` plugin is experimental +> and its API may change. + +`DeepAgentsPlugin` is a **client-level** plugin: add it to `Client.connect(...)` +and the SDK propagates it to any Worker built from that client. Add it on exactly +one side. + +## Samples + +| Sample | Description | +|--------|-------------| +| [hello_world](hello_world) | Minimal single-shot Deep Agent; a bare `model=` string auto-routed through the model activity. Start here. | +| [react_agent](react_agent) | Tool-calling loop showing the explicit per-tool choice: `activity_as_tool` for an existing activity, `tool_as_activity` for an I/O tool, plus an explicit `TemporalModel`. | +| [human_in_the_loop](human_in_the_loop) | Pause on `interrupt_on` and resume via the native LangGraph protocol, mapped to a Temporal Query + Update. | +| [continue_as_new](continue_as_new) | Long-running agent that carries messages and the model/tool result cache across continue-as-new via `run_deep_agent`. | +| [filesystem_backend](filesystem_backend) | Durable real filesystem I/O by wrapping a `FilesystemBackend` in `TemporalBackend`. | +| [subagents](subagents) | Durability propagates across the agent tree — sub-agent model calls become activities with no per-sub-agent wiring. | +| [streaming](streaming) | Stream model chunks to external subscribers via `streaming_topic` + `WorkflowStream`, keeping the durable result identical. | +| [langsmith_tracing](langsmith_tracing) | Compose `DeepAgentsPlugin` with `LangSmithPlugin` for durable execution + LLM tracing. | + +## Prerequisites + +1. Install dependencies: + + ```bash + uv sync --group deepagents + ``` + + > The `temporalio-contrib-deepagents` plugin is experimental and not yet + > published to PyPI, so it is not part of the `deepagents` group above. + > Until it publishes, install it from a local checkout of the SDK (adjust + > the path to wherever your `sdk-python` checkout lives): + > + > ```bash + > uv pip install ../sdk-python/temporalio/contrib/deepagents + > ``` + > + > The plugin uses a namespace-package overlay layout, so install it + > non-editable (no `-e`) — an editable install cannot map its sources onto + > `temporalio.contrib.deepagents`. Once it is on PyPI this step goes away + > and you can add `temporalio-contrib-deepagents` to the `deepagents` group. + +2. Configure a model provider. The samples use + `anthropic:claude-sonnet-4-5`, which needs an Anthropic API key: + + ```bash + export ANTHROPIC_API_KEY=... + ``` + + To use a different provider, change the `model=` string in the sample's + `workflow.py` and set that provider's credentials (the plugin resolves the + model worker-side via LangChain's `init_chat_model`). + +3. Start a [Temporal dev server](https://docs.temporal.io/cli#start-dev-server): + + ```bash + temporal server start-dev + ``` + +## Running a Sample + +> **Use `uv run --no-sync`.** Because the experimental plugin is installed +> out-of-band (`uv pip install …` above) and is not in any dependency group, a +> bare `uv run` or `uv sync` re-syncs the environment to the lockfile first and +> uninstalls it. `--no-sync` runs against the environment as-is. (Once the +> plugin publishes and joins the `deepagents` group, the flag is unnecessary.) + +Most samples have two scripts. Start the Worker first, then the Workflow starter +in a separate terminal: + +```bash +# Terminal 1: start the Worker +uv run --no-sync deepagents_plugin//run_worker.py + +# Terminal 2: start the Workflow +uv run --no-sync deepagents_plugin//run_workflow.py +``` + +For example, to run the hello world sample: + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/hello_world/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/hello_world/run_workflow.py +``` + +The `langsmith_tracing` sample instead bundles the worker and starter into a +single driver: + +```bash +uv run --no-sync deepagents_plugin/langsmith_tracing/main.py +``` + +## Key Features Demonstrated + +- **Durable model invocation** — every LLM call runs in an `invoke_model` + activity with configurable timeouts and retries; a bare `model=` string is + auto-routed, or use `TemporalModel(...)` explicitly. +- **Explicit Workflow-vs-Activity tool choice** — `activity_as_tool`, + `tool_as_activity`, and `TemporalBackend` move I/O out of workflow code. +- **Human-in-the-loop** — the native LangGraph `interrupt_on` return value + mapped to a Temporal Query and Update. +- **Long-lived agents** — `run_deep_agent(continue_as_new_after=...)` carries + messages and the result cache across continue-as-new. +- **Sub-agent durability** — sub-agents inherit the durable model object with no + extra wiring. +- **Streaming** — forward model chunks to external subscribers while keeping the + durable result unchanged. +- **Observability** — compose with `LangSmithPlugin` for tracing. + +## Related + +- [Temporal Deep Agents plugin](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/deepagents) +- [LangChain Deep Agents](https://github.com/langchain-ai/deepagents) +- [langgraph_plugin](../langgraph_plugin) — for agents built directly as LangGraph graphs diff --git a/deepagents_plugin/__init__.py b/deepagents_plugin/__init__.py new file mode 100644 index 00000000..99ad713a --- /dev/null +++ b/deepagents_plugin/__init__.py @@ -0,0 +1 @@ +"""Temporal Deep Agents plugin samples.""" diff --git a/deepagents_plugin/continue_as_new/README.md b/deepagents_plugin/continue_as_new/README.md new file mode 100644 index 00000000..e23fb799 --- /dev/null +++ b/deepagents_plugin/continue_as_new/README.md @@ -0,0 +1,48 @@ +# Continue as New + +A long-running research agent whose conversation could outgrow Temporal's +workflow-history limit. `run_deep_agent(..., continue_as_new_after=N)` keeps the +run bounded: once history passes `N` events and the agent still has pending +todos, it snapshots the accumulated messages **and** the model/tool result cache +and continues into a fresh run. Completed model/tool calls are reused from the +carried cache rather than re-run. + +The `@workflow.run` signature is `run(self, input, state_snapshot=None)`, where +`input` is the messages mapping and `state_snapshot` is how `run_deep_agent` +threads carried state into the continued run. On a continue-as-new the workflow +is re-invoked with `args=[input, snapshot]`, so `input` must be passed straight +into `run_deep_agent` — re-wrapping it would nest a dict where a message is +expected and corrupt the carried conversation. Durability rides on the default +in-workflow `InMemorySaver` (rehydrated by replay); a database-backed +checkpointer would do I/O from workflow code and is not replay-safe. + +## What This Sample Demonstrates + +- `run_deep_agent(agent, input, continue_as_new_after=..., state_snapshot=...)` +- The `run(self, input, state_snapshot=None)` continue-as-new contract +- Carrying both messages and the result cache across continue-as-new + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, an `ANTHROPIC_API_KEY` in your +environment, and a running Temporal dev server (`temporal server start-dev`). + +> The experimental plugin is not in the `deepagents` group — install it as shown +> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or +> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/continue_as_new/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/continue_as_new/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `LongResearchAgent` driven by `run_deep_agent` with `continue_as_new_after` | +| `run_worker.py` | Adds `DeepAgentsPlugin`, starts the worker | +| `run_workflow.py` | Executes the workflow and prints the result | diff --git a/deepagents_plugin/continue_as_new/__init__.py b/deepagents_plugin/continue_as_new/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/continue_as_new/run_worker.py b/deepagents_plugin/continue_as_new/run_worker.py new file mode 100644 index 00000000..c9a7e874 --- /dev/null +++ b/deepagents_plugin/continue_as_new/run_worker.py @@ -0,0 +1,29 @@ +"""Worker for the continue-as-new sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.worker import Worker + +from deepagents_plugin.continue_as_new.workflow import LongResearchAgent + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[DeepAgentsPlugin()], + ) + + worker = Worker( + client, + task_queue="deepagents-continue-as-new", + workflows=[LongResearchAgent], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/continue_as_new/run_workflow.py b/deepagents_plugin/continue_as_new/run_workflow.py new file mode 100644 index 00000000..9eb1acc9 --- /dev/null +++ b/deepagents_plugin/continue_as_new/run_workflow.py @@ -0,0 +1,37 @@ +"""Start the long-running research agent workflow.""" + +import asyncio +import os + +from temporalio.client import Client + +from deepagents_plugin.continue_as_new.workflow import LongResearchAgent + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + result = await client.execute_workflow( + LongResearchAgent.run, + # The workflow's first arg is the messages mapping (run_deep_agent's + # continue-as-new contract), not a bare question string. + { + "messages": [ + { + "role": "user", + "content": ( + "Research the tradeoffs between Raft and Paxos and " + "summarize them." + ), + } + ] + }, + id="deepagents-continue-as-new", + task_queue="deepagents-continue-as-new", + ) + + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/continue_as_new/workflow.py b/deepagents_plugin/continue_as_new/workflow.py new file mode 100644 index 00000000..f93f0673 --- /dev/null +++ b/deepagents_plugin/continue_as_new/workflow.py @@ -0,0 +1,58 @@ +"""Long-running research agent that carries state across continue-as-new. + +A long conversation would bloat workflow history until it hits Temporal's limit. +``run_deep_agent(agent, input, continue_as_new_after=N, state_snapshot=...)`` +solves this: once the current turn finishes past the ``N``-event threshold and +there is still pending work, it snapshots the accumulated messages **and** the +model/tool result cache and continues into a fresh run — so completed model/tool +calls are reused, not re-run, after the continue-as-new. + +The contract ``run_deep_agent`` requires is that the ``@workflow.run`` method +accepts the carried state, i.e. its signature is +``run(self, input, state_snapshot=None)`` where ``input`` is the messages +mapping. On a continue-as-new, ``run_deep_agent`` re-invokes the workflow with +``args=[input, snapshot]``, so ``input`` must be passed straight through — not +re-wrapped — or the carried conversation is corrupted. Only an in-workflow +``InMemorySaver`` (the default) is replay-safe; a durable checkpointer would do +I/O from workflow code. +""" + +# @@@SNIPSTART python-deepagents-continue-as-new-workflow +from typing import Any + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + from temporalio.contrib.deepagents import run_deep_agent + + +@workflow.defn +class LongResearchAgent: + @workflow.run + async def run( + self, input: dict[str, Any], state_snapshot: dict | None = None + ) -> str: + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + system_prompt=( + "You are a research agent. Break large tasks into todos and work " + "through them until the research is complete." + ), + ) + result = await run_deep_agent( + agent, + # ``input`` is the messages mapping. Pass it through unchanged: on a + # continue-as-new, run_deep_agent re-invokes this method with the + # carried input as its first arg, so re-wrapping it here would nest a + # dict where a message is expected and corrupt the conversation. + input, + # Continue-as-new once history passes this many events and the agent + # still has pending todos. Tune to your model's turn size. + continue_as_new_after=10_000, + state_snapshot=state_snapshot, + ) + return result["messages"][-1].content + + +# @@@SNIPEND diff --git a/deepagents_plugin/filesystem_backend/README.md b/deepagents_plugin/filesystem_backend/README.md new file mode 100644 index 00000000..2648013f --- /dev/null +++ b/deepagents_plugin/filesystem_backend/README.md @@ -0,0 +1,49 @@ +# Filesystem Backend + +Give a Deep Agent durable, real filesystem access. The agent's built-in file +tools (`write_file`, `read_file`, `ls`, …) delegate to a *backend*. Wrapping a +`FilesystemBackend` with `TemporalBackend(inner, activity_options=...)` routes +each file operation through a `deepagents.backend_op` activity, so real disk I/O +happens in an activity worker instead of in workflow code. + +Contrast this with the default `StateBackend`, whose "files" live in agent state +— that is pure workflow state and correctly stays in the workflow with no +wrapping. `TemporalBackend` is only for backends that do real I/O. + +The scratch directory (`root_dir`) is chosen client-side and passed in as a +workflow argument, so the workflow never reads the environment or the disk +directly. + +## What This Sample Demonstrates + +- `TemporalBackend` wrapping a real-I/O `FilesystemBackend` +- The agent's built-in file tools running their I/O as `backend_op` activities +- Keeping the workflow deterministic by passing `root_dir` in as an argument + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, an `ANTHROPIC_API_KEY` in your +environment, and a running Temporal dev server (`temporal server start-dev`). + +> The experimental plugin is not in the `deepagents` group — install it as shown +> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or +> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/filesystem_backend/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/filesystem_backend/run_workflow.py +``` + +By default the starter creates a temporary scratch directory; set +`DEEPAGENTS_WORKDIR` to point the agent at a directory of your choice. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `FilesystemAgent` wrapping a `FilesystemBackend` in `TemporalBackend` | +| `run_worker.py` | Adds `DeepAgentsPlugin`, starts the worker | +| `run_workflow.py` | Chooses a scratch dir, executes the workflow, prints the result | diff --git a/deepagents_plugin/filesystem_backend/__init__.py b/deepagents_plugin/filesystem_backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/filesystem_backend/run_worker.py b/deepagents_plugin/filesystem_backend/run_worker.py new file mode 100644 index 00000000..722fc158 --- /dev/null +++ b/deepagents_plugin/filesystem_backend/run_worker.py @@ -0,0 +1,29 @@ +"""Worker for the filesystem backend sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.worker import Worker + +from deepagents_plugin.filesystem_backend.workflow import FilesystemAgent + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[DeepAgentsPlugin()], + ) + + worker = Worker( + client, + task_queue="deepagents-filesystem-backend", + workflows=[FilesystemAgent], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/filesystem_backend/run_workflow.py b/deepagents_plugin/filesystem_backend/run_workflow.py new file mode 100644 index 00000000..2f1c1361 --- /dev/null +++ b/deepagents_plugin/filesystem_backend/run_workflow.py @@ -0,0 +1,43 @@ +"""Start the filesystem backend workflow against a scratch directory. + +The workflow's agent writes a file and reads it back; each file operation runs +as a ``deepagents.backend_op`` activity, so the real disk write happens in an +activity worker, not in workflow code. +""" + +import asyncio +import os +import tempfile + +from temporalio.client import Client + +from deepagents_plugin.filesystem_backend.workflow import FilesystemAgent + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + # Choose the scratch directory here (client side), then pass it in — the + # workflow itself never reads the environment or the filesystem. + root_dir = os.environ.get("DEEPAGENTS_WORKDIR") or tempfile.mkdtemp( + prefix="deepagents-fs-" + ) + print(f"Agent working directory: {root_dir}") + + result = await client.execute_workflow( + FilesystemAgent.run, + args=[ + root_dir, + "Write a short haiku about durability to notes.txt, then read it " + "back and tell me what it says.", + ], + id="deepagents-filesystem-backend", + task_queue="deepagents-filesystem-backend", + ) + + print(f"Result: {result}") + print(f"Files written under: {root_dir}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/filesystem_backend/workflow.py b/deepagents_plugin/filesystem_backend/workflow.py new file mode 100644 index 00000000..77f9ed04 --- /dev/null +++ b/deepagents_plugin/filesystem_backend/workflow.py @@ -0,0 +1,55 @@ +"""Durable real filesystem I/O via ``TemporalBackend``. + +A Deep Agent's built-in file tools (``write_file``, ``read_file``, ``ls``, …) +delegate to a *backend*. The default ``StateBackend`` keeps files in agent state +— pure workflow state, replay-safe, no wrapping needed. A ``FilesystemBackend``, +by contrast, touches real disk, which must not happen from workflow code. + +``TemporalBackend(inner, activity_options=...)`` wraps such a backend so each +file operation the agent's tools invoke becomes a ``deepagents.backend_op`` +activity instead of running in the workflow. The agent code is unchanged; only +the backend is wrapped. + +``root_dir`` is passed in as a workflow argument (rather than read from the +environment inside the workflow) to keep the workflow deterministic. +""" + +# @@@SNIPSTART python-deepagents-filesystem-backend-workflow +from datetime import timedelta + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + from deepagents.backends import FilesystemBackend + from temporalio.contrib.deepagents import TemporalBackend + + +@workflow.defn +class FilesystemAgent: + @workflow.run + async def run(self, root_dir: str, instruction: str) -> str: + # Wrap the real-I/O backend so every file op runs in an activity. + backend = TemporalBackend( + # virtual_mode roots every path the agent uses under root_dir, so the + # agent's file tools stay sandboxed to this working directory. + FilesystemBackend(root_dir=root_dir, virtual_mode=True), + activity_options={"start_to_close_timeout": timedelta(seconds=30)}, + ) + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + # TemporalBackend delegates the backend protocol to the wrapped + # backend at runtime, which the static type can't see through. + backend=backend, # type: ignore[arg-type] + system_prompt=( + "You are a file-savvy assistant. Use the write_file and " + "read_file tools to complete the task." + ), + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": instruction}]} + ) + return result["messages"][-1].content + + +# @@@SNIPEND diff --git a/deepagents_plugin/hello_world/README.md b/deepagents_plugin/hello_world/README.md new file mode 100644 index 00000000..44b88248 --- /dev/null +++ b/deepagents_plugin/hello_world/README.md @@ -0,0 +1,39 @@ +# Hello World + +The simplest Deep Agents + Temporal sample: build a Deep Agent with +`create_deep_agent(...)` and invoke it once. The agent code is unchanged from a +non-Temporal program — adding `DeepAgentsPlugin()` to the client is what makes +the single LLM call run as a durable `deepagents.invoke_model` activity, with +Temporal-managed retries and timeouts. + +## What This Sample Demonstrates + +- Wiring `DeepAgentsPlugin` onto the client (it auto-propagates to the worker) +- Building a Deep Agent from a bare `model="anthropic:claude-sonnet-4-5"` string, + which the plugin auto-routes through the model activity +- Driving the agent with `await agent.ainvoke(...)` from a `@workflow.defn` + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, an `ANTHROPIC_API_KEY` in your +environment, and a running Temporal dev server (`temporal server start-dev`). + +> The experimental plugin is not in the `deepagents` group — install it as shown +> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or +> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/hello_world/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/hello_world/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `HelloWorldAgent`: one Deep Agent, one `ainvoke` | +| `run_worker.py` | Adds `DeepAgentsPlugin` to the client, starts the worker | +| `run_workflow.py` | Executes the workflow and prints the result | diff --git a/deepagents_plugin/hello_world/__init__.py b/deepagents_plugin/hello_world/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/hello_world/run_worker.py b/deepagents_plugin/hello_world/run_worker.py new file mode 100644 index 00000000..442f0af4 --- /dev/null +++ b/deepagents_plugin/hello_world/run_worker.py @@ -0,0 +1,37 @@ +"""Worker for the hello world sample. + +``DeepAgentsPlugin`` is a client-level plugin: add it to ``Client.connect(...)`` +and the SDK propagates it to every Worker built from that client. The plugin +registers the ``deepagents.*`` activities and installs the LangChain-aware data +converter, so the worker needs no other wiring. +""" + +# @@@SNIPSTART python-deepagents-hello-world-worker +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.worker import Worker + +from deepagents_plugin.hello_world.workflow import HelloWorldAgent + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[DeepAgentsPlugin()], + ) + + worker = Worker( + client, + task_queue="deepagents-hello-world", + workflows=[HelloWorldAgent], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +# @@@SNIPEND diff --git a/deepagents_plugin/hello_world/run_workflow.py b/deepagents_plugin/hello_world/run_workflow.py new file mode 100644 index 00000000..a0a87343 --- /dev/null +++ b/deepagents_plugin/hello_world/run_workflow.py @@ -0,0 +1,27 @@ +"""Start the hello world workflow and print the agent's answer.""" + +# @@@SNIPSTART python-deepagents-hello-world-run-workflow +import asyncio +import os + +from temporalio.client import Client + +from deepagents_plugin.hello_world.workflow import HelloWorldAgent + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + result = await client.execute_workflow( + HelloWorldAgent.run, + "What is Temporal in one sentence?", + id="deepagents-hello-world", + task_queue="deepagents-hello-world", + ) + + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) +# @@@SNIPEND diff --git a/deepagents_plugin/hello_world/workflow.py b/deepagents_plugin/hello_world/workflow.py new file mode 100644 index 00000000..eeba257f --- /dev/null +++ b/deepagents_plugin/hello_world/workflow.py @@ -0,0 +1,33 @@ +"""Minimal single-shot Deep Agent, made durable by the plugin. + +The workflow builds a vanilla ``create_deep_agent(...)`` and drives it with +``await agent.ainvoke(...)`` — exactly the code you would write outside Temporal. +The only reason it is durable is that a ``DeepAgentsPlugin`` is wired onto the +client (see ``run_worker.py``): the bare ``model="anthropic:..."`` string is +auto-routed through the ``deepagents.invoke_model`` activity, so the LLM call +gets Temporal-managed retries and timeouts while the agent's control loop +replays deterministically in the workflow. +""" + +# @@@SNIPSTART python-deepagents-hello-world-workflow +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + + +@workflow.defn +class HelloWorldAgent: + @workflow.run + async def run(self, question: str) -> str: + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + system_prompt="You are a helpful assistant. Answer concisely.", + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": question}]} + ) + return result["messages"][-1].content + + +# @@@SNIPEND diff --git a/deepagents_plugin/human_in_the_loop/README.md b/deepagents_plugin/human_in_the_loop/README.md new file mode 100644 index 00000000..5f925abc --- /dev/null +++ b/deepagents_plugin/human_in_the_loop/README.md @@ -0,0 +1,53 @@ +# Human in the Loop + +Pause a Deep Agent for human approval before it runs a guarded tool, then resume +it with the human's decision — using the **native LangGraph interrupt protocol** +(the plugin adds no shim of its own). + +`create_deep_agent(..., interrupt_on={"book_trip": True})` plus an in-workflow +`InMemorySaver` checkpointer make the agent pause before calling `book_trip`. +With a checkpointer configured, `ainvoke` *returns* the pending approval under +the SDK-native `__interrupt__` key rather than raising. Because the loop runs in +the workflow, the pause surfaces in workflow code, where it is mapped to +Temporal messaging: + +- a **`@workflow.query`** (`pending_approval`) exposes the pending prompt; +- a **`@workflow.update`** (`resume`) feeds the decision back via + `Command(resume={"decisions": [{"type": decision}]})`. + +The `InMemorySaver` is replay-safe (its state is workflow memory rehydrated by +replay); the `thread_id` is the workflow id. + +## What This Sample Demonstrates + +- `interrupt_on` + an in-workflow `InMemorySaver` checkpointer +- Reading the native `__interrupt__` return value in workflow code +- Mapping the pause to a Temporal Query and the resume to a Temporal Update + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, an `ANTHROPIC_API_KEY` in your +environment, and a running Temporal dev server (`temporal server start-dev`). + +> The experimental plugin is not in the `deepagents` group — install it as shown +> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or +> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/human_in_the_loop/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/human_in_the_loop/run_workflow.py +``` + +The starter polls the query until the agent pauses, prints the approval prompt, +sends an `approve` decision via the update, and prints the final result. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `HumanInTheLoopAgent` with `interrupt_on`, a Query, and an Update | +| `run_worker.py` | Adds `DeepAgentsPlugin`, starts the worker | +| `run_workflow.py` | Starts the workflow, polls the query, sends the resume update | diff --git a/deepagents_plugin/human_in_the_loop/__init__.py b/deepagents_plugin/human_in_the_loop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/human_in_the_loop/run_worker.py b/deepagents_plugin/human_in_the_loop/run_worker.py new file mode 100644 index 00000000..1eced08d --- /dev/null +++ b/deepagents_plugin/human_in_the_loop/run_worker.py @@ -0,0 +1,29 @@ +"""Worker for the human-in-the-loop sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.worker import Worker + +from deepagents_plugin.human_in_the_loop.workflow import HumanInTheLoopAgent + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[DeepAgentsPlugin()], + ) + + worker = Worker( + client, + task_queue="deepagents-human-in-the-loop", + workflows=[HumanInTheLoopAgent], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/human_in_the_loop/run_workflow.py b/deepagents_plugin/human_in_the_loop/run_workflow.py new file mode 100644 index 00000000..298d8626 --- /dev/null +++ b/deepagents_plugin/human_in_the_loop/run_workflow.py @@ -0,0 +1,45 @@ +"""Start the HITL workflow, wait for the approval prompt, then approve it. + +Starts the agent, polls the ``pending_approval`` query until the agent pauses on +the guarded ``book_trip`` tool, then sends the ``resume`` update with a decision. +In a real app the query result would be shown to a person and the update sent +from a UI. +""" + +import asyncio +import os + +from temporalio.client import Client + +from deepagents_plugin.human_in_the_loop.workflow import HumanInTheLoopAgent + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + handle = await client.start_workflow( + HumanInTheLoopAgent.run, + "Rome", + id="deepagents-human-in-the-loop", + task_queue="deepagents-human-in-the-loop", + ) + + # Poll the query until the agent surfaces the pending approval. + for _ in range(100): + pending = await handle.query(HumanInTheLoopAgent.pending_approval) + if pending is not None: + print(f"Approval requested: {pending}") + break + await asyncio.sleep(0.5) + else: + raise RuntimeError("workflow never surfaced an approval prompt") + + print("Approving...") + await handle.execute_update(HumanInTheLoopAgent.resume, "approve") + + result = await handle.result() + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/human_in_the_loop/workflow.py b/deepagents_plugin/human_in_the_loop/workflow.py new file mode 100644 index 00000000..fceca7da --- /dev/null +++ b/deepagents_plugin/human_in_the_loop/workflow.py @@ -0,0 +1,96 @@ +"""Human-in-the-loop: the native LangGraph interrupt mapped to Query + Update. + +``create_deep_agent(..., interrupt_on=...)`` makes the agent pause before a +guarded tool runs. With an in-workflow ``InMemorySaver`` checkpointer, LangGraph +does *not* raise out of ``ainvoke`` — it returns the current state with an +``__interrupt__`` entry describing the pending approval. Because the agent loop +runs in the workflow, that pause surfaces directly in workflow code. + +The plugin adds no shim here. The recommended Temporal mapping is: + +* expose the pending approval via a ``@workflow.query`` so a client can read it; +* resume via a ``@workflow.update`` that feeds the human's decision back with the + native ``Command(resume={"decisions": [...]})`` protocol; its validator rejects + unsupported decisions before they are accepted into workflow history. + +The ``InMemorySaver`` is replay-safe because its state lives in the workflow's +own memory (rehydrated by deterministic replay); the ``thread_id`` is the stable +workflow id. +""" + +# @@@SNIPSTART python-deepagents-human-in-the-loop-workflow +from datetime import timedelta + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import InMemorySaver + from langgraph.types import Command + from temporalio.contrib.deepagents import tool_as_activity + + +@workflow.defn +class HumanInTheLoopAgent: + def __init__(self) -> None: + self._pending: str | None = None + self._decision: str | None = None + self._resumed = False + + @workflow.run + async def run(self, city: str) -> str: + def book_trip(city: str) -> str: + """Book a trip to a city (requires human approval).""" + return f"Booked a trip to {city}." + + trip_tool = tool_as_activity( + book_trip, start_to_close_timeout=timedelta(seconds=30) + ) + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + tools=[trip_tool], + interrupt_on={"book_trip": True}, + checkpointer=InMemorySaver(), + ) + config = RunnableConfig(configurable={"thread_id": workflow.info().workflow_id}) + + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": f"Book a trip to {city}."}]}, + config=config, + ) + # LangGraph returns (not raises) the pending approval under __interrupt__. + pending = result.get("__interrupt__") + if pending: + self._pending = str(getattr(pending[0], "value", pending[0])) + # Block until a client approves/rejects via the `resume` update. + await workflow.wait_condition(lambda: self._resumed) + # No longer paused: the query goes back to reporting None. + self._pending = None + result = await agent.ainvoke( + Command(resume={"decisions": [{"type": self._decision}]}), + config=config, + ) + return result["messages"][-1].content + + @workflow.query + def pending_approval(self) -> str | None: + """Return the pending approval prompt, or ``None`` if not paused.""" + return self._pending + + @workflow.update + async def resume(self, decision: str) -> None: + """Resume the paused agent with ``"approve"`` or ``"reject"``.""" + self._decision = decision + self._resumed = True + + @resume.validator + def validate_resume(self, decision: str) -> None: + # Runs before the update is accepted, keeping invalid decisions out of + # workflow history entirely. Only the decisions this workflow feeds to + # `Command(resume=...)` are allowed. + if decision not in ("approve", "reject"): + raise ValueError('decision must be "approve" or "reject"') + + +# @@@SNIPEND diff --git a/deepagents_plugin/langsmith_tracing/README.md b/deepagents_plugin/langsmith_tracing/README.md new file mode 100644 index 00000000..9ef3b4eb --- /dev/null +++ b/deepagents_plugin/langsmith_tracing/README.md @@ -0,0 +1,46 @@ +# LangSmith Tracing + +Run a Deep Agent durably **and** trace its LLM calls to +[LangSmith](https://smith.langchain.com/), by composing `LangSmithPlugin` +alongside `DeepAgentsPlugin`. The plugin carries no tracing context of its own; +the observability plugin captures the model calls that `DeepAgentsPlugin` runs as +activities. Per the plugin's composition guidance, the observability plugin is +registered **before** `DeepAgentsPlugin`. + +Following the shipped tracing samples, this scenario bundles the worker and +starter into a single `main.py` and has no automated test (it requires external +API keys). + +## What This Sample Demonstrates + +- Composing `DeepAgentsPlugin` with `temporalio.contrib.langsmith.LangSmithPlugin` +- Registering the observability plugin before `DeepAgentsPlugin` + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, a running Temporal dev server +(`temporal server start-dev`), and these environment variables: + +```bash +export ANTHROPIC_API_KEY=... +export LANGSMITH_API_KEY=... # or LANGCHAIN_API_KEY +export LANGSMITH_TRACING=true +``` + +The experimental plugin is not in the `deepagents` group — install it as shown +in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or a +bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. Then run the +single-process driver: + +```bash +uv run --no-sync deepagents_plugin/langsmith_tracing/main.py +``` + +Traces appear in your LangSmith project. + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `TracedAgent`: an ordinary Deep Agent | +| `main.py` | Composes `LangSmithPlugin` + `DeepAgentsPlugin`, runs the workflow once | diff --git a/deepagents_plugin/langsmith_tracing/__init__.py b/deepagents_plugin/langsmith_tracing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/langsmith_tracing/main.py b/deepagents_plugin/langsmith_tracing/main.py new file mode 100644 index 00000000..eef0c099 --- /dev/null +++ b/deepagents_plugin/langsmith_tracing/main.py @@ -0,0 +1,49 @@ +"""Run the LangSmith tracing Deep Agents sample. + +Single-process driver: starts a Worker, executes the Workflow once, prints the +result, then shuts down. Composes ``LangSmithPlugin`` with ``DeepAgentsPlugin`` +so the agent runs durably *and* its LLM calls are traced to LangSmith. Per the +plugin's "Composing with other plugins" guidance, the observability plugin is +registered **before** ``DeepAgentsPlugin``. + +Requires ``ANTHROPIC_API_KEY`` and ``LANGSMITH_API_KEY`` (or ``LANGCHAIN_API_KEY``) +in the environment. +""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.contrib.langsmith import LangSmithPlugin +from temporalio.worker import Worker + +from deepagents_plugin.langsmith_tracing.workflow import TracedAgent + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[ + # Register observability first, then the Deep Agents plugin. + LangSmithPlugin(), + DeepAgentsPlugin(), + ], + ) + + async with Worker( + client, + task_queue="deepagents-langsmith-tracing", + workflows=[TracedAgent], + ): + result = await client.execute_workflow( + TracedAgent.run, + "What is durable execution, in one sentence?", + id="deepagents-langsmith-tracing", + task_queue="deepagents-langsmith-tracing", + ) + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/langsmith_tracing/workflow.py b/deepagents_plugin/langsmith_tracing/workflow.py new file mode 100644 index 00000000..9744b84d --- /dev/null +++ b/deepagents_plugin/langsmith_tracing/workflow.py @@ -0,0 +1,30 @@ +"""A Deep Agent whose durable execution is also traced to LangSmith. + +The workflow itself is an ordinary Deep Agent — the tracing comes entirely from +composing ``LangSmithPlugin`` alongside ``DeepAgentsPlugin`` on the client (see +``main.py``). The plugin carries no tracing context of its own; the observability +plugin captures the LLM calls that ``DeepAgentsPlugin`` runs as activities. +""" + +# @@@SNIPSTART python-deepagents-langsmith-tracing-workflow +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + + +@workflow.defn +class TracedAgent: + @workflow.run + async def run(self, question: str) -> str: + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + system_prompt="You are a helpful assistant.", + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": question}]} + ) + return result["messages"][-1].content + + +# @@@SNIPEND diff --git a/deepagents_plugin/react_agent/README.md b/deepagents_plugin/react_agent/README.md new file mode 100644 index 00000000..02f66ea5 --- /dev/null +++ b/deepagents_plugin/react_agent/README.md @@ -0,0 +1,48 @@ +# React Agent (tool-calling loop) + +A Deep Agent that loops over tool calls until it produces a final answer. This +sample demonstrates the **explicit Workflow-vs-Activity choice per tool** — the +core decision when making an agent durable: + +- **`activity_as_tool`** exposes an existing Temporal activity (`get_weather`) + as a Deep Agents tool. The tool advertises the activity's argument schema to + the model and dispatches to the activity via `workflow.execute_activity`. +- **`tool_as_activity`** wraps a LangChain tool (`web_search`) whose body does + I/O so its execution runs as a `deepagents.invoke_tool` activity instead of + inline in the workflow. + +The model is built explicitly as `TemporalModel(model="anthropic:claude-sonnet-4-5")` +to show the non-auto path. Every model turn and every tool call is a durable +activity. + +## What This Sample Demonstrates + +- `TemporalModel` constructed explicitly (vs. the auto-wrapped `model=` string) +- `activity_as_tool` for an existing `@activity.defn` +- `tool_as_activity` for a LangChain tool that does I/O +- Registering the user activity on the worker alongside the plugin's activities + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, an `ANTHROPIC_API_KEY` in your +environment, and a running Temporal dev server (`temporal server start-dev`). + +> The experimental plugin is not in the `deepagents` group — install it as shown +> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or +> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/react_agent/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/react_agent/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `get_weather` activity, `web_search` tool, and the `ReactAgent` workflow | +| `run_worker.py` | Adds `DeepAgentsPlugin`, registers `get_weather`, starts the worker | +| `run_workflow.py` | Executes the workflow and prints the result | diff --git a/deepagents_plugin/react_agent/__init__.py b/deepagents_plugin/react_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/react_agent/run_worker.py b/deepagents_plugin/react_agent/run_worker.py new file mode 100644 index 00000000..531375f7 --- /dev/null +++ b/deepagents_plugin/react_agent/run_worker.py @@ -0,0 +1,37 @@ +"""Worker for the react agent sample. + +The plugin registers the ``deepagents.*`` activities automatically, but the +user's own ``get_weather`` activity (exposed to the agent via +``activity_as_tool``) must be registered on the worker like any other activity. +The ``web_search`` tool wrapped with ``tool_as_activity`` needs no separate +registration — it runs through the plugin's ``deepagents.invoke_tool`` activity. +""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.worker import Worker + +from deepagents_plugin.react_agent.workflow import ReactAgent, get_weather + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[DeepAgentsPlugin()], + ) + + worker = Worker( + client, + task_queue="deepagents-react-agent", + workflows=[ReactAgent], + activities=[get_weather], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/react_agent/run_workflow.py b/deepagents_plugin/react_agent/run_workflow.py new file mode 100644 index 00000000..a7470bc3 --- /dev/null +++ b/deepagents_plugin/react_agent/run_workflow.py @@ -0,0 +1,25 @@ +"""Start the react agent workflow and print the final answer.""" + +import asyncio +import os + +from temporalio.client import Client + +from deepagents_plugin.react_agent.workflow import ReactAgent + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + result = await client.execute_workflow( + ReactAgent.run, + "What's the weather in Seattle, and what is Temporal known for?", + id="deepagents-react-agent", + task_queue="deepagents-react-agent", + ) + + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/react_agent/workflow.py b/deepagents_plugin/react_agent/workflow.py new file mode 100644 index 00000000..990d533d --- /dev/null +++ b/deepagents_plugin/react_agent/workflow.py @@ -0,0 +1,79 @@ +"""Tool-calling Deep Agent: the explicit Workflow-vs-Activity choice per tool. + +A Deep Agent holds its tools in-workflow. A tool that only reads/writes agent +state is pure and belongs there; a tool that does real I/O must not run in +workflow code. This sample shows the two explicit ways to move a tool's work to +an activity: + +* ``activity_as_tool`` — surface an existing ``@activity.defn`` (``get_weather``) + as a Deep Agents tool. Temporal adopters already have activities; they should + not have to re-declare them. +* ``tool_as_activity`` — wrap a LangChain tool (``web_search``) whose body does + I/O so its execution runs as a ``deepagents.invoke_tool`` activity. + +The model is constructed explicitly as ``TemporalModel(...)`` to show the +non-auto path (the plugin would otherwise wrap a bare ``model=`` string for you). +Every model turn and every tool call in the loop is a durable activity. +""" + +from datetime import timedelta + +from temporalio import activity, workflow + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + from langchain_core.tools import tool + from temporalio.contrib.deepagents import ( + TemporalModel, + activity_as_tool, + tool_as_activity, + ) + + +# @@@SNIPSTART python-deepagents-react-agent-activity +@activity.defn +async def get_weather(city: str) -> str: + """Return the current weather for a city.""" + # A real implementation would call a weather API here; this is a stand-in. + return f"It is sunny and 22C in {city}." + + +# @@@SNIPEND + + +# @@@SNIPSTART python-deepagents-react-agent-workflow +@tool +def web_search(query: str) -> str: + """Search the web for a query and return a short result.""" + # Real I/O (an HTTP call) would go here; wrapped with tool_as_activity so it + # runs in an activity, not in workflow code. + return f"Top result for {query!r}: Temporal makes code durable." + + +@workflow.defn +class ReactAgent: + @workflow.run + async def run(self, question: str) -> str: + weather_tool = activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(seconds=30), + ) + search_tool = tool_as_activity( + web_search, + start_to_close_timeout=timedelta(seconds=30), + ) + agent = create_deep_agent( + model=TemporalModel(model="anthropic:claude-sonnet-4-5"), + tools=[weather_tool, search_tool], + system_prompt=( + "You are a research assistant. Use the get_weather and " + "web_search tools when they help answer the question." + ), + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": question}]} + ) + return result["messages"][-1].content + + +# @@@SNIPEND diff --git a/deepagents_plugin/streaming/README.md b/deepagents_plugin/streaming/README.md new file mode 100644 index 00000000..472817e9 --- /dev/null +++ b/deepagents_plugin/streaming/README.md @@ -0,0 +1,47 @@ +# Streaming + +Stream a Deep Agent's model output to external subscribers in real time, while +keeping the durable workflow result identical to the non-streaming path. + +Constructing the plugin with `DeepAgentsPlugin(streaming_topic=...)` flips model +dispatch from `deepagents.invoke_model` to `deepagents.invoke_model_streaming`. +The streaming activity coalesces chunk batches and publishes them to a +`temporalio.contrib.workflow_streams` topic; the aggregated final message is +still returned to the workflow. + +Streaming is async-only, so the workflow drives an explicit +`TemporalModel.astream(...)` and hosts a `WorkflowStream` so subscribers can +attach by workflow id. The starter subscribes to the topic and prints chunks as +they arrive. + +## What This Sample Demonstrates + +- `DeepAgentsPlugin(streaming_topic=...)` to enable streaming dispatch +- Hosting a `WorkflowStream` in the workflow and driving `TemporalModel.astream` +- A client subscribing via `WorkflowStreamClient` and decoding `AIMessageChunk` + batches with `langchain_core.load.load` + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, an `ANTHROPIC_API_KEY` in your +environment, and a running Temporal dev server (`temporal server start-dev`). + +> The experimental plugin is not in the `deepagents` group — install it as shown +> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or +> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/streaming/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/streaming/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `StreamingWorkflow` hosting a `WorkflowStream`, driving `astream` | +| `run_worker.py` | Adds `DeepAgentsPlugin(streaming_topic=...)`, starts the worker | +| `run_workflow.py` | Starts the workflow and prints streamed chunks live | diff --git a/deepagents_plugin/streaming/__init__.py b/deepagents_plugin/streaming/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/streaming/run_worker.py b/deepagents_plugin/streaming/run_worker.py new file mode 100644 index 00000000..024a9e1c --- /dev/null +++ b/deepagents_plugin/streaming/run_worker.py @@ -0,0 +1,34 @@ +"""Worker for the streaming sample. + +``streaming_topic=`` is what turns on streaming: with it set, the plugin routes +model calls through the ``deepagents.invoke_model_streaming`` activity, which +publishes chunk batches to that workflow-streams topic. +""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.worker import Worker + +from deepagents_plugin.streaming.workflow import STREAMING_TOPIC, StreamingWorkflow + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[DeepAgentsPlugin(streaming_topic=STREAMING_TOPIC)], + ) + + worker = Worker( + client, + task_queue="deepagents-streaming", + workflows=[StreamingWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/streaming/run_workflow.py b/deepagents_plugin/streaming/run_workflow.py new file mode 100644 index 00000000..06877b83 --- /dev/null +++ b/deepagents_plugin/streaming/run_workflow.py @@ -0,0 +1,60 @@ +"""Start the streaming workflow and print model chunks live. + +Subscribes to the workflow-streams topic the streaming activity publishes to and +renders each chunk's text as it arrives. Each published item is an +``AIMessageChunk`` in ``langchain_core.load.dumpd`` form, so it is reconstructed +with ``langchain_core.load.load``. The final aggregated message is also returned +as the workflow result (identical to the non-streaming path). +""" + +import asyncio +import os +from datetime import timedelta + +from langchain_core.load import load +from temporalio.client import Client +from temporalio.contrib.workflow_streams import WorkflowStreamClient + +from deepagents_plugin.streaming.workflow import STREAMING_TOPIC, StreamingWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + workflow_id = "deepagents-streaming" + + handle = await client.start_workflow( + StreamingWorkflow.run, + "Write a short paragraph about durable execution.", + id=workflow_id, + task_queue="deepagents-streaming", + ) + + async def consume() -> None: + stream = WorkflowStreamClient.create(client, workflow_id) + async for item in stream.subscribe( + [STREAMING_TOPIC], + from_offset=0, + result_type=dict, + poll_cooldown=timedelta(milliseconds=50), + ): + chunk = load(item.data) + text = getattr(chunk, "content", "") + if text: + print(text, end="", flush=True) + + consume_task = asyncio.create_task(consume()) + result = await handle.result() + print() + + # The workflow has completed; give the subscriber a beat to drain, then stop. + consume_task.cancel() + try: + await consume_task + except asyncio.CancelledError: + pass + + print(f"Final result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/streaming/workflow.py b/deepagents_plugin/streaming/workflow.py new file mode 100644 index 00000000..06c8be69 --- /dev/null +++ b/deepagents_plugin/streaming/workflow.py @@ -0,0 +1,42 @@ +"""Stream model output to external subscribers while keeping a durable result. + +Constructing the plugin with ``DeepAgentsPlugin(streaming_topic=...)`` flips +model dispatch from ``deepagents.invoke_model`` to +``deepagents.invoke_model_streaming``: the streaming activity coalesces chunk +batches and publishes them to a ``temporalio.contrib.workflow_streams`` topic for +subscribers, while the aggregated final message is still returned to the workflow +(so the durable result is identical to the non-streaming path). + +Streaming is async-only, so the workflow drives an explicit +``TemporalModel.astream(...)``. It hosts a ``WorkflowStream`` so external +subscribers can attach by workflow id (see ``run_workflow.py``). +""" + +# @@@SNIPSTART python-deepagents-streaming-workflow +from temporalio import workflow +from temporalio.contrib.workflow_streams import WorkflowStream + +with workflow.unsafe.imports_passed_through(): + from langchain_core.messages import HumanMessage + from temporalio.contrib.deepagents import TemporalModel + +STREAMING_TOPIC = "model-chunks" + + +@workflow.defn +class StreamingWorkflow: + def __init__(self) -> None: + # Host the stream so the publish-Signal handler is registered before the + # streaming activity (the external publisher) starts publishing. + self.stream = WorkflowStream() + + @workflow.run + async def run(self, prompt: str) -> str: + model = TemporalModel(model="anthropic:claude-sonnet-4-5") + parts: list[str] = [] + async for chunk in model.astream([HumanMessage(content=prompt)]): + parts.append(str(chunk.content)) + return "".join(parts) + + +# @@@SNIPEND diff --git a/deepagents_plugin/subagents/README.md b/deepagents_plugin/subagents/README.md new file mode 100644 index 00000000..b7541f50 --- /dev/null +++ b/deepagents_plugin/subagents/README.md @@ -0,0 +1,44 @@ +# Subagents + +Durability propagates across the entire agent tree with **no per-sub-agent +wiring**. A coordinator agent built with `create_deep_agent(..., subagents=[...])` +delegates to its sub-agents through the built-in `task` tool. Deep Agents builds +each sub-agent as a separate graph, but they inherit the parent's `model` object +by default — so because the plugin makes that one model object durable, every +sub-agent's model call also becomes a `deepagents.invoke_model` activity +automatically. + +In this sample the coordinator delegates deep investigation to a `researcher` +sub-agent and then synthesizes a final answer. Both the coordinator's and the +researcher's LLM calls run as durable activities. + +## What This Sample Demonstrates + +- `create_deep_agent(subagents=[...])` with a delegated `researcher` +- Durability inheritance: sub-agent model calls route through activities with no + extra wiring + +## Running the Sample + +Prerequisites: `uv sync --group deepagents`, an `ANTHROPIC_API_KEY` in your +environment, and a running Temporal dev server (`temporal server start-dev`). + +> The experimental plugin is not in the `deepagents` group — install it as shown +> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or +> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it. + +```bash +# Terminal 1 +uv run --no-sync deepagents_plugin/subagents/run_worker.py + +# Terminal 2 +uv run --no-sync deepagents_plugin/subagents/run_workflow.py +``` + +## Files + +| File | Description | +|------|-------------| +| `workflow.py` | `SubagentsWorkflow`: a coordinator with a `researcher` sub-agent | +| `run_worker.py` | Adds `DeepAgentsPlugin`, starts the worker | +| `run_workflow.py` | Executes the workflow and prints the result | diff --git a/deepagents_plugin/subagents/__init__.py b/deepagents_plugin/subagents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deepagents_plugin/subagents/run_worker.py b/deepagents_plugin/subagents/run_worker.py new file mode 100644 index 00000000..c5abb563 --- /dev/null +++ b/deepagents_plugin/subagents/run_worker.py @@ -0,0 +1,29 @@ +"""Worker for the subagents sample.""" + +import asyncio +import os + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.worker import Worker + +from deepagents_plugin.subagents.workflow import SubagentsWorkflow + + +async def main() -> None: + client = await Client.connect( + os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[DeepAgentsPlugin()], + ) + + worker = Worker( + client, + task_queue="deepagents-subagents", + workflows=[SubagentsWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/subagents/run_workflow.py b/deepagents_plugin/subagents/run_workflow.py new file mode 100644 index 00000000..226b52cb --- /dev/null +++ b/deepagents_plugin/subagents/run_workflow.py @@ -0,0 +1,25 @@ +"""Start the subagents workflow and print the coordinator's synthesized answer.""" + +import asyncio +import os + +from temporalio.client import Client + +from deepagents_plugin.subagents.workflow import SubagentsWorkflow + + +async def main() -> None: + client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")) + + result = await client.execute_workflow( + SubagentsWorkflow.run, + "Investigate how Temporal handles workflow retries and summarize it.", + id="deepagents-subagents", + task_queue="deepagents-subagents", + ) + + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/deepagents_plugin/subagents/workflow.py b/deepagents_plugin/subagents/workflow.py new file mode 100644 index 00000000..f5155444 --- /dev/null +++ b/deepagents_plugin/subagents/workflow.py @@ -0,0 +1,48 @@ +"""Durability propagates across the whole agent tree — no per-sub-agent wiring. + +A coordinator built with ``create_deep_agent(..., subagents=[...])`` delegates to +its sub-agents via the built-in ``task`` tool. Deep Agents builds each sub-agent +as a separate graph, but they inherit the parent's ``model`` object by default. +Because the plugin makes that model object durable (each generation is a +``deepagents.invoke_model`` activity), every sub-agent's model call is +automatically durable too — you wire the plugin once and the whole tree is +covered. + +Here the coordinator delegates deep investigation to a ``researcher`` sub-agent +and then synthesizes a final answer; both the coordinator's and the researcher's +model calls run as activities. +""" + +# @@@SNIPSTART python-deepagents-subagents-workflow +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + + +@workflow.defn +class SubagentsWorkflow: + @workflow.run + async def run(self, question: str) -> str: + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + system_prompt=( + "You are a research coordinator. Delegate deep investigation to " + "the researcher sub-agent via the task tool, then synthesize a " + "final answer." + ), + subagents=[ + { + "name": "researcher", + "description": "Researches a topic in depth and reports findings.", + "system_prompt": "You research topics thoroughly and report back.", + } + ], + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": question}]} + ) + return result["messages"][-1].content + + +# @@@SNIPEND diff --git a/pyproject.toml b/pyproject.toml index e8f3ebd8..0b956b86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,23 @@ dev = [ "poethepoet>=0.36.0", ] bedrock = ["boto3>=1.34.92,<2"] +# The Deep Agents plugin requires Python >= 3.11, so every dependency in this +# group is gated on the interpreter version. On 3.10 the group resolves to +# nothing and the samples are skipped. +# +# The plugin distribution itself (`temporalio-contrib-deepagents`) is +# experimental and not yet on PyPI, so it is NOT listed here — a hard, +# unpublishable dependency would break `uv lock`/`uv sync` for everyone. +# Install it separately with the interim install step documented in +# deepagents_plugin/README.md until it publishes; then add +# `temporalio-contrib-deepagents>=0.1.0 ; python_version >= '3.11'` to this group. +deepagents = [ + "deepagents>=0.6.12,<0.7 ; python_version >= '3.11'", + "langchain>=1.3.11,<2 ; python_version >= '3.11'", + "langchain-core>=1.4.8,<2 ; python_version >= '3.11'", + "langchain-anthropic>=1.4.7 ; python_version >= '3.11'", + "temporalio[langsmith]>=1.30.0 ; python_version >= '3.11'", +] dsl = ["pyyaml>=6.0.1,<7", "types-pyyaml>=6.0.12,<7", "dacite>=1.8.1,<2"] encryption = ["cryptography>=38.0.1,<39", "aiohttp>=3.13.3,<4"] external-storage = [ @@ -102,6 +119,7 @@ packages = [ "custom_converter", "custom_decorator", "custom_metric", + "deepagents_plugin", "dsl", "encryption", "external_storage", diff --git a/tests/deepagents_plugin/__init__.py b/tests/deepagents_plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/deepagents_plugin/conftest.py b/tests/deepagents_plugin/conftest.py new file mode 100644 index 00000000..95fd7507 --- /dev/null +++ b/tests/deepagents_plugin/conftest.py @@ -0,0 +1,40 @@ +"""Collection guard for the Deep Agents plugin tests. + +The `temporalio-contrib-deepagents` plugin (and its `deepagents` / +`langchain` runtime deps) require Python >= 3.11. It is also experimental and +not yet published to PyPI, so it is deliberately kept out of every dependency +group — the canonical `poe test` run (`uv run --all-groups pytest`) never +installs it. These test modules import `temporalio.contrib.deepagents` at module +load, which would raise `ImportError` during collection whenever the plugin is +absent (any interpreter) or the interpreter is < 3.11 — failing the whole +session. + +A module-level `pytest.mark.skipif` cannot help here: the mark is only read +*after* the module is imported, so the import error fires first. `collect_ignore` +is evaluated before any test module is imported, so it skips these files +cleanly when the plugin is unavailable while still running them once it is +installed on 3.11+. + +The guard performs a real (guarded) import rather than `find_spec`: the +subpackage can exist on disk while its runtime deps do not — e.g. a +plugin-carrying `temporalio` build installed without the `deepagents` +dependency group — and only an actual import proves the test modules can load. +The version check runs first so the import is never attempted on interpreters +the plugin does not support. +""" + +import sys + +collect_ignore_glob: list[str] = [] + +_plugin_available = False +if sys.version_info >= (3, 11): + try: + import temporalio.contrib.deepagents # noqa: F401 + + _plugin_available = True + except ImportError: + _plugin_available = False + +if not _plugin_available: + collect_ignore_glob = ["*_test.py"] diff --git a/tests/deepagents_plugin/continue_as_new_test.py b/tests/deepagents_plugin/continue_as_new_test.py new file mode 100644 index 00000000..c9a8ad32 --- /dev/null +++ b/tests/deepagents_plugin/continue_as_new_test.py @@ -0,0 +1,111 @@ +import uuid +from typing import Any + +from temporalio import workflow +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin, run_deep_agent +from temporalio.contrib.deepagents.testing import mock_model_provider +from temporalio.worker import Worker + +from deepagents_plugin.continue_as_new.workflow import LongResearchAgent + + +async def test_continue_as_new(client: Client) -> None: + # The sample's threshold (continue_as_new_after=10_000) is far above what a + # short scripted run reaches, so this exercises the run_deep_agent contract + # on the real sample workflow without triggering a continue-as-new. The + # first arg is the messages mapping (the run_deep_agent contract), not a + # bare string. + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["The research is complete: done."]), + ) + task_queue = f"deepagents-continue-as-new-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[LongResearchAgent], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + LongResearchAgent.run, + {"messages": [{"role": "user", "content": "Summarize durable execution."}]}, + id=f"deepagents-continue-as-new-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert "complete" in result + + +class _ScriptedAgent: + """A stand-in compiled agent that appends one message per turn and keeps a + todo pending until the conversation grows, forcing continue-as-new. + + It is not a LangChain object — it just satisfies the ``ainvoke`` shape that + ``run_deep_agent`` drives, so the continue-as-new path is exercised without a + model provider or the LangChain import tree. + """ + + async def ainvoke(self, input: Any) -> dict: + messages = list(input.get("messages", [])) if isinstance(input, dict) else [] + messages = [*messages, "step"] + done = len(messages) >= 3 + return { + "messages": messages, + "todos": [ + {"content": "research", "status": "completed" if done else "pending"} + ], + } + + +@workflow.defn +class _ContinueAsNewProbe: + @workflow.run + async def run(self, input: dict, state_snapshot: dict | None = None) -> dict: + # Threshold of 1 continues-as-new as soon as there is pending work. + # ``input`` is threaded straight through, exactly as LongResearchAgent + # does — re-wrapping it would nest a dict where a message is expected and + # corrupt the carried conversation. + return await run_deep_agent( + _ScriptedAgent(), + input, + continue_as_new_after=1, + state_snapshot=state_snapshot, + ) + + +async def test_continue_as_new_carries_conversation(client: Client) -> None: + # Low threshold + persistent pending work actually triggers + # workflow.continue_as_new, so this guards the scenario's headline feature: + # the carried conversation must survive the boundary well-formed. + plugin = DeepAgentsPlugin() + task_queue = f"deepagents-continue-as-new-probe-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[_ContinueAsNewProbe], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + _ContinueAsNewProbe.run, + {"messages": ["start"]}, + id=f"deepagents-continue-as-new-probe-{uuid.uuid4()}", + task_queue=task_queue, + ) + + # Reaching >= 3 messages is only possible if each pre-continue-as-new + # snapshot was carried into the continued run and merged. Every carried + # message stays a plain string — no nested-dict corruption from re-wrapping + # the input across the boundary. + assert len(result["messages"]) >= 3, result + assert all(isinstance(m, str) for m in result["messages"]), result + assert result["todos"][0]["status"] == "completed" diff --git a/tests/deepagents_plugin/filesystem_backend_test.py b/tests/deepagents_plugin/filesystem_backend_test.py new file mode 100644 index 00000000..babc4f92 --- /dev/null +++ b/tests/deepagents_plugin/filesystem_backend_test.py @@ -0,0 +1,61 @@ +import uuid + +from langchain_core.messages import AIMessage +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.contrib.deepagents.testing import mock_model_provider +from temporalio.worker import Worker + +from deepagents_plugin.filesystem_backend.workflow import FilesystemAgent + + +async def test_filesystem_backend(client: Client, tmp_path) -> None: + # Script the agent's built-in file tools: write the note, read it back, then + # report. Each file op crosses the activity boundary via TemporalBackend, so + # the real disk write happens in an activity, not in workflow code. + write_turn = AIMessage( + content="", + tool_calls=[ + { + "name": "write_file", + "args": {"file_path": "/notes.txt", "content": "hello"}, + "id": "call-write", + } + ], + ) + read_turn = AIMessage( + content="", + tool_calls=[ + { + "name": "read_file", + "args": {"file_path": "/notes.txt"}, + "id": "call-read", + } + ], + ) + final = AIMessage(content="The note says: hello") + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider([write_turn, read_turn, final]), + ) + task_queue = f"deepagents-filesystem-backend-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[FilesystemAgent], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + FilesystemAgent.run, + args=[str(tmp_path), "Write 'hello' to notes.txt and read it back."], + id=f"deepagents-filesystem-backend-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert "hello" in result + # The write really landed on disk — performed in the backend_op activity. + assert (tmp_path / "notes.txt").read_text() == "hello" diff --git a/tests/deepagents_plugin/hello_world_test.py b/tests/deepagents_plugin/hello_world_test.py new file mode 100644 index 00000000..71bf58d0 --- /dev/null +++ b/tests/deepagents_plugin/hello_world_test.py @@ -0,0 +1,37 @@ +import uuid + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.contrib.deepagents.testing import mock_model_provider +from temporalio.worker import Worker + +from deepagents_plugin.hello_world.workflow import HelloWorldAgent + + +async def test_hello_world(client: Client) -> None: + # A scripted model so the test runs offline (no ANTHROPIC_API_KEY needed). + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider( + ["Temporal is a durable execution platform."] + ), + ) + task_queue = f"deepagents-hello-world-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[HelloWorldAgent], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + HelloWorldAgent.run, + "What is Temporal?", + id=f"deepagents-hello-world-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert "durable" in result diff --git a/tests/deepagents_plugin/human_in_the_loop_test.py b/tests/deepagents_plugin/human_in_the_loop_test.py new file mode 100644 index 00000000..e020d788 --- /dev/null +++ b/tests/deepagents_plugin/human_in_the_loop_test.py @@ -0,0 +1,53 @@ +import asyncio +import uuid + +from langchain_core.messages import AIMessage +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.contrib.deepagents.testing import mock_model_provider +from temporalio.worker import Worker + +from deepagents_plugin.human_in_the_loop.workflow import HumanInTheLoopAgent + + +async def test_human_in_the_loop_approve(client: Client) -> None: + # First model turn asks to book the trip (the guarded tool → interrupt); + # after the human approves, the second turn reports the booking. + ask = AIMessage( + content="", + tool_calls=[{"name": "book_trip", "args": {"city": "Rome"}, "id": "c1"}], + ) + done = AIMessage(content="Booked a trip to Rome.") + plugin = DeepAgentsPlugin(model_provider=mock_model_provider([ask, done])) + task_queue = f"deepagents-human-in-the-loop-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[HumanInTheLoopAgent], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + HumanInTheLoopAgent.run, + "Rome", + id=f"deepagents-human-in-the-loop-{uuid.uuid4()}", + task_queue=task_queue, + ) + + # Wait for the agent to pause (surfaced via the query), then approve via + # an update. Bounded poll so a regression fails fast. + for _ in range(100): + if await handle.query(HumanInTheLoopAgent.pending_approval) is not None: + break + await asyncio.sleep(0.1) + else: + raise AssertionError("workflow never surfaced the pending approval") + + await handle.execute_update(HumanInTheLoopAgent.resume, "approve") + result = await handle.result() + + assert "Rome" in result diff --git a/tests/deepagents_plugin/react_agent_test.py b/tests/deepagents_plugin/react_agent_test.py new file mode 100644 index 00000000..7f5b9be2 --- /dev/null +++ b/tests/deepagents_plugin/react_agent_test.py @@ -0,0 +1,48 @@ +import uuid + +from langchain_core.messages import AIMessage +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.contrib.deepagents.testing import mock_model_provider +from temporalio.worker import Worker + +from deepagents_plugin.react_agent.workflow import ReactAgent, get_weather + + +async def test_react_agent(client: Client) -> None: + # Script the model through the tool loop: call get_weather, then web_search, + # then answer. The tools run for real (get_weather as an activity, web_search + # wrapped via tool_as_activity); only the model turns are scripted. + call_weather = AIMessage( + content="", + tool_calls=[{"name": "get_weather", "args": {"city": "Paris"}, "id": "c1"}], + ) + call_search = AIMessage( + content="", + tool_calls=[{"name": "web_search", "args": {"query": "Temporal"}, "id": "c2"}], + ) + final = AIMessage(content="It is sunny in Paris and Temporal keeps code durable.") + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider([call_weather, call_search, final]), + ) + task_queue = f"deepagents-react-agent-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[ReactAgent], + activities=[get_weather], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + ReactAgent.run, + "What's the weather in Paris, and what is Temporal?", + id=f"deepagents-react-agent-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert "durable" in result diff --git a/tests/deepagents_plugin/streaming_test.py b/tests/deepagents_plugin/streaming_test.py new file mode 100644 index 00000000..7c144050 --- /dev/null +++ b/tests/deepagents_plugin/streaming_test.py @@ -0,0 +1,72 @@ +import asyncio +import uuid +from datetime import timedelta + +from langchain_core.load import load +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.contrib.deepagents.testing import mock_model_provider +from temporalio.contrib.workflow_streams import WorkflowStreamClient +from temporalio.worker import Worker + +from deepagents_plugin.streaming.workflow import STREAMING_TOPIC, StreamingWorkflow + + +async def test_streaming(client: Client) -> None: + # streaming_topic=... flips model dispatch to the streaming activity, which + # publishes chunks to the topic while still returning the aggregated message + # as the durable result. An in-test subscriber collects the chunks. + expected = "Streamed answer." + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider([expected]), + streaming_topic=STREAMING_TOPIC, + ) + task_queue = f"deepagents-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, + ): + workflow_id = f"deepagents-streaming-{uuid.uuid4()}" + handle = await client.start_workflow( + StreamingWorkflow.run, + "Write a sentence about durable execution.", + id=workflow_id, + task_queue=task_queue, + ) + + chunks: list[str] = [] + + async def consume() -> None: + stream = WorkflowStreamClient.create(client, workflow_id) + async for item in stream.subscribe( + [STREAMING_TOPIC], + from_offset=0, + result_type=dict, + poll_cooldown=timedelta(milliseconds=10), + ): + text = getattr(load(item.data), "content", "") + if text: + chunks.append(text) + # The subscription is open-ended; return once the full answer + # has been observed. + if expected in "".join(chunks): + return + + consume_task = asyncio.create_task(consume()) + result = await handle.result() + # No fixed sleep: the subscriber exits as soon as it has seen the + # streamed content; the timeout only bounds a regression. + await asyncio.wait_for(consume_task, timeout=10.0) + + # The durable result matches the non-streaming path... + assert expected in result + # ...and the same content was streamed out to the subscriber. + assert chunks, "expected at least one streamed chunk" + assert expected in "".join(chunks) diff --git a/tests/deepagents_plugin/subagents_test.py b/tests/deepagents_plugin/subagents_test.py new file mode 100644 index 00000000..7520f844 --- /dev/null +++ b/tests/deepagents_plugin/subagents_test.py @@ -0,0 +1,37 @@ +import uuid + +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin +from temporalio.contrib.deepagents.testing import mock_model_provider +from temporalio.worker import Worker + +from deepagents_plugin.subagents.workflow import SubagentsWorkflow + + +async def test_subagents(client: Client) -> None: + # The coordinator and its researcher sub-agent share the durable model object, + # so every model call routes through an activity with no per-sub-agent wiring. + # A scripted model keeps the run offline; we assert the tree runs to a result. + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["Coordinated research answer."]), + ) + task_queue = f"deepagents-subagents-{uuid.uuid4()}" + + config = client.config() + config["plugins"] = [*config["plugins"], plugin] + client = Client(**config) + + async with Worker( + client, + task_queue=task_queue, + workflows=[SubagentsWorkflow], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + SubagentsWorkflow.run, + "Investigate durable execution and report back.", + id=f"deepagents-subagents-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert "Coordinated" in result diff --git a/uv.lock b/uv.lock index 6063f75a..f5d6c6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -413,6 +413,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/2c/8a0b02d60a1dbbae7faa5af30484b016aa3023f9833dfc0d19b0b770dd6a/botocore-1.39.11-py3-none-any.whl", hash = "sha256:1545352931a8a186f3e977b1e1a4542d7d434796e274c3c62efd0210b5ea76dc", size = 13876276, upload-time = "2025-07-22T19:26:35.164Z" }, ] +[[package]] +name = "bracex" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/f5/4473ad9b48cd0420a2d762a3750fa0e078e23e060b1af72662e5987e5530/bracex-3.0.tar.gz", hash = "sha256:b73f718d6bd98d8419e45df02426c86e9967c179949f779340d6c3a8c83b9111", size = 43162, upload-time = "2026-06-30T00:43:35.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/2e/68781b78e764e5ccc4af1e3d27e060069c73af90234853fa80000e7ee79d/bracex-3.0-py3-none-any.whl", hash = "sha256:3833e61c2f092d5aa0468fa2e6c6e990a306185abf763b6d122f0158e59c58a5", size = 11738, upload-time = "2026-06-30T00:43:34.196Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -690,6 +699,23 @@ 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 = "deepagents" +version = "0.6.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain", version = "1.3.13", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-anthropic", version = "1.4.8", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-google-genai" }, + { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" } }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/49/af7219b3c13520fee047bb807cfaefba17f8e4584c551d946773589a4f08/deepagents-0.6.12-py3-none-any.whl", hash = "sha256:28b8fa0119ca0a689e3e18e288c4634e4046062acfc87a1cb34289d3af3a1c88", size = 236120, upload-time = "2026-06-25T17:26:51.736Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -736,7 +762,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -845,6 +871,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -2169,9 +2204,12 @@ wheels = [ name = "langchain" version = "1.3.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "langchain-core" }, - { name = "langgraph" }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/e5/6350e77a9e2764eaafcb2d581cbf0b800f53c6bc98fdf5ebc85f3a931ded/langchain-1.3.1.tar.gz", hash = "sha256:bc283c220233230f48b8e50ab1fbf1b688bcb206d933fa448d40a9b143177f62", size = 581329, upload-time = "2026-05-15T18:14:55.368Z" } @@ -2179,13 +2217,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/11/3d7ed10b535413a07ed5e15682abcb77f3c4204ac49586977a495f9b24e6/langchain-1.3.1-py3-none-any.whl", hash = "sha256:154e9c30c90b391eba4315296f6bf6b6fac6b058ddea4cc771a10470968fe36f", size = 114345, upload-time = "2026-05-15T18:14:53.984Z" }, ] +[[package]] +name = "langchain" +version = "1.3.13" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph", version = "1.2.9", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/fd716d0273c8495482953bd63461bf02f71f3a8f4a2fe6c0a70a0e6ff799/langchain-1.3.13.tar.gz", hash = "sha256:bcf874680f31e9970f0db2264509df5bc2115d9680e9d651d537eb49bf1a7d8a", size = 642868, upload-time = "2026-07-10T23:06:08.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/c6/dc676c632f3d20c88789b0726c43ad5e039c25338cc3bb7090fc247d1522/langchain-1.3.13-py3-none-any.whl", hash = "sha256:20a8fe4b1dea7db74356f7d2b5455c4970099b9f7f53c2122ea97f115c907fbd", size = 136911, upload-time = "2026-07-10T23:06:07.012Z" }, +] + [[package]] name = "langchain-anthropic" version = "1.4.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "anthropic" }, - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/e3/d2f9dec95602524b1cfb4be2747ba5bc38d32501b2a56cb4bcb76e80bb45/langchain_anthropic-1.4.3.tar.gz", hash = "sha256:f8a2442463c0629b1b3110eaeaa56fdbdc87df2a802f8c7f5ecf611eb4874ec8", size = 685219, upload-time = "2026-05-03T17:33:27.118Z" } @@ -2193,14 +2255,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/55/482a1968c95275e8be6d8c1e53b54f0f7be0b8b155ce1608c947a95cf543/langchain_anthropic-1.4.3-py3-none-any.whl", hash = "sha256:65466e0f2f95909a009708f2958e917dfdbfab79c612b4484a30866a85e1f291", size = 50389, upload-time = "2026-05-03T17:33:25.671Z" }, ] +[[package]] +name = "langchain-anthropic" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "anthropic" }, + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/22/40ab129b08329ca295b391aa1d48267692b42594757084c6918e22b655ac/langchain_anthropic-1.4.8.tar.gz", hash = "sha256:c76891b2044d56105ff13c106ed12650637b53bd598a4bdf15b4796eefa2a4ec", size = 708524, upload-time = "2026-06-26T21:28:46.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/14/746235c4da89d9bc6a608c5f489f628e03feb8f697195c146e452c8f23c8/langchain_anthropic-1.4.8-py3-none-any.whl", hash = "sha256:778e9301b6fd517824f76ec1776975ce8add97a1f6a36c50ae3c2f4b03a66f7f", size = 52366, upload-time = "2026-06-26T21:28:45.535Z" }, +] + [[package]] name = "langchain-core" version = "1.4.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "jsonpatch" }, - { name = "langchain-protocol" }, - { name = "langsmith" }, + { name = "langchain-protocol", version = "0.0.15", source = { registry = "https://pypi.org/simple" } }, + { name = "langsmith", version = "0.8.9", source = { registry = "https://pypi.org/simple" } }, { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -2213,10 +2299,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, ] +[[package]] +name = "langchain-core" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol", version = "0.0.18", source = { registry = "https://pypi.org/simple" } }, + { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/b9/e937d0a90b26540bff07e7a7c64349f3b29c2dcc36257cd1cd3fdce17f2a/langchain_core-1.4.9.tar.gz", hash = "sha256:f8078901145bed0466755277500a5a22822a7b628808c4c0a28d4fc88895fcf2", size = 967294, upload-time = "2026-07-08T20:06:54.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/70/ade2fada52772798ef815b6352b59e71b116aa0c32c3aef5be3dc2cbed12/langchain_core-1.4.9-py3-none-any.whl", hash = "sha256:28e3909e2a10cc81504952d795ac0a9e014c0018121ef89d48dd396fa09ec624", size = 558293, upload-time = "2026-07-08T20:06:52.382Z" }, +] + +[[package]] +name = "langchain-google-genai" +version = "4.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filetype" }, + { name = "google-genai" }, + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/0c/bc60dabc362ca7c6ffe8c4bcc2f724c7e566b43eb230cee51419f88f784c/langchain_google_genai-4.2.7.tar.gz", hash = "sha256:03b1463ffe4d42435f43c7870467f2215f684bb46400d2543435d10157c80ac7", size = 281605, upload-time = "2026-07-06T13:51:58.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/f9/d73d1e712591723aaddb7a7b1e94978cd2320c29acfe0d26b6169a2f26f0/langchain_google_genai-4.2.7-py3-none-any.whl", hash = "sha256:0d9c388d0e6c629718fca6abb19c6fdca728a9a7873d0324c1ec821288b5b571", size = 70702, upload-time = "2026-07-06T13:51:57.499Z" }, +] + [[package]] name = "langchain-protocol" version = "0.0.15" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "typing-extensions" }, ] @@ -2225,15 +2356,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" }, ] +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + [[package]] name = "langgraph" version = "1.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, { name = "langgraph-checkpoint" }, { name = "langgraph-prebuilt" }, - { name = "langgraph-sdk" }, + { name = "langgraph-sdk", version = "0.3.14", source = { registry = "https://pypi.org/simple" } }, { name = "pydantic" }, { name = "xxhash" }, ] @@ -2242,12 +2395,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/e8/e3304ac0015c2bdb04ad9785e4ed65c788855ce7857ce6104dd2f5d322db/langgraph-1.2.0-py3-none-any.whl", hash = "sha256:03fd5895a8d4b70db1ff63ebc3bacead29dd20cd794a8b1a483e7ec9018f7a65", size = 234262, upload-time = "2026-05-12T03:46:37.971Z" }, ] +[[package]] +name = "langgraph" +version = "1.2.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk", version = "0.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" }, +] + [[package]] name = "langgraph-checkpoint" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "ormsgpack" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/b4/6005c5dd88ad484fe6235d4c43a0d2cee7e91b08ad85a180985c2662df87/langgraph_checkpoint-4.1.0.tar.gz", hash = "sha256:e5bb304e30fc1363ac8fcb5f7dee5ca2185d77fe475b0d01de2c5f91324c2c21", size = 181942, upload-time = "2026-05-12T03:33:49.888Z" } @@ -2260,7 +2438,8 @@ name = "langgraph-prebuilt" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "langgraph-checkpoint" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } @@ -2272,6 +2451,9 @@ wheels = [ name = "langgraph-sdk" version = "0.3.14" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "httpx" }, { name = "orjson" }, @@ -2281,10 +2463,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/96/1c9f9fbfe756ddd850a2585e7f1949d8ebb97fdaa7a5eff8f45ed1314670/langgraph_sdk-0.3.14-py3-none-any.whl", hash = "sha256:68935bf6f4924eda92617a9e5dfb4f4281197508c648cb9d62ff083907607f9d", size = 97028, upload-time = "2026-05-05T18:40:02.099Z" }, ] +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "httpx" }, + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-protocol", version = "0.0.18", source = { registry = "https://pypi.org/simple" } }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, +] + [[package]] name = "langsmith" version = "0.8.9" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, @@ -2302,6 +2510,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/2f/a701663c9fb4d9630448622a684bc372b4905b9a6dbe2297d55a70fde04e/langsmith-0.8.9-py3-none-any.whl", hash = "sha256:c9519cabc75568d088df045710d1b86eae9780c91054528b2aa7e6cb1fc80c52", size = 403165, upload-time = "2026-06-03T17:56:07.226Z" }, ] +[[package]] +name = "langsmith" +version = "0.8.18" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, +] + [[package]] name = "lazy-object-proxy" version = "1.12.0" @@ -4975,10 +5211,12 @@ google-adk = [ { name = "google-adk" }, ] langgraph = [ - { name = "langgraph" }, + { name = "langgraph", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langgraph", version = "1.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] langsmith = [ - { name = "langsmith" }, + { name = "langsmith", version = "0.8.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] openai-agents = [ { name = "mcp" }, @@ -5014,6 +5252,13 @@ cloud-export-to-parquet = [ { name = "pandas", marker = "python_full_version < '4'" }, { name = "pyarrow" }, ] +deepagents = [ + { name = "deepagents", marker = "python_full_version >= '3.11'" }, + { name = "langchain", version = "1.3.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "langchain-anthropic", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", version = "1.4.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "temporalio", extra = ["langsmith"], marker = "python_full_version >= '3.11'" }, +] dev = [ { name = "fakeredis" }, { name = "frozenlist" }, @@ -5052,13 +5297,17 @@ google-adk = [ { name = "temporalio", extra = ["google-adk"] }, ] langgraph = [ - { name = "langchain" }, - { name = "langchain-anthropic" }, - { name = "langgraph" }, + { name = "langchain", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain", version = "1.3.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "langchain-anthropic", version = "1.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain-anthropic", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "langgraph", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langgraph", version = "1.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "temporalio", extra = ["langgraph", "langsmith"] }, ] langsmith-tracing = [ - { name = "langsmith" }, + { name = "langsmith", version = "0.8.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "openai" }, { name = "temporalio", extra = ["langsmith", "pydantic"] }, ] @@ -5106,6 +5355,13 @@ cloud-export-to-parquet = [ { name = "pandas", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = ">=2.3.3,<3" }, { name = "pyarrow", specifier = ">=19.0.1" }, ] +deepagents = [ + { name = "deepagents", marker = "python_full_version >= '3.11'", specifier = ">=0.6.12,<0.7" }, + { name = "langchain", marker = "python_full_version >= '3.11'", specifier = ">=1.3.11,<2" }, + { name = "langchain-anthropic", marker = "python_full_version >= '3.11'", specifier = ">=1.4.7" }, + { name = "langchain-core", marker = "python_full_version >= '3.11'", specifier = ">=1.4.8,<2" }, + { name = "temporalio", extras = ["langsmith"], marker = "python_full_version >= '3.11'", specifier = ">=1.30.0" }, +] dev = [ { name = "fakeredis", specifier = ">=2,<3" }, { name = "frozenlist", specifier = ">=1.4.0,<2" }, @@ -5635,6 +5891,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "wcmatch" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/25/1da725838132221e33568973da484ff43813662ccc06ebf7f6e3abddfcd5/wcmatch-11.0.tar.gz", hash = "sha256:55d95c2447789712774b198ceec72939e88b5618f1f8f0a9b605bf7740b63b96", size = 141360, upload-time = "2026-07-10T05:50:24.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/12/f38b6fee116274d7221743caab07d765032e1370bb54cad8714f87aeb0e8/wcmatch-11.0-py3-none-any.whl", hash = "sha256:3a5977ace27e075eef67eb03d539563f1a19018b62881949a42932cf66926934", size = 42914, upload-time = "2026-07-10T05:50:22.995Z" }, +] + [[package]] name = "wcwidth" version = "0.7.0"