Skip to content

Commit 05070f6

Browse files
authored
Add Deep Agents plugin samples (#328)
* Add Deep Agents plugin samples * Address review feedback on HITL state, conftest guard, and streaming test - human_in_the_loop: clear the pending-approval prompt on resume so the query honors its documented contract, and add an update validator that rejects decisions other than approve/reject before they enter history - tests conftest: replace find_spec with a guarded import so collection is skipped when the plugin package exists but its runtime deps do not - streaming test: replace the fixed sleep-then-cancel drain with a condition-based subscriber awaited via wait_for, matching the other streaming tests * Address review feedback: CODEOWNERS, docs, stream draining, stronger tests - CODEOWNERS: add /deepagents_plugin/ and /tests/deepagents_plugin/ for the AI SDK team, matching the sibling AI suites - Suite README: state the Python >= 3.11 floor in Prerequisites (on 3.10 the dependency group silently resolves to nothing) - streaming/run_workflow.py: drain the subscriber until the full durable result has been printed (bounded by a timeout) instead of cancelling it immediately and dropping tail chunks - subagents_test: script the coordinator -> task tool -> researcher -> synthesis path so the delegation headline is actually exercised, and assert three invoke_model activities in history - hello_world_test: assert the model call was scheduled as a deepagents.invoke_model activity (shared count_scheduled_activities helper) - pyproject: cap langchain-anthropic at <2 like its group siblings * Align samples with the plugin's recommended patterns - Drop the workflow.unsafe.imports_passed_through() guards from all eight workflows: the plugin passes the deepagents/LangChain import tree through the sandbox itself, and its README highlights bare imports as the intended developer experience. hello_world carries a comment explaining why no guard is needed. Verified by the full test suite (real sandboxed worker) plus an ad-hoc sandbox run of the untested langsmith_tracing workflow. - continue_as_new: use run_deep_agent's default server-suggested mode (the documented recommended mode) instead of a hardcoded event threshold; the probe test retains continue_as_new_after=1 as explicit-override coverage. - react_agent: build the agent with create_temporal_deep_agent and per-agent activity_options — the recommended way to scope model-call timeouts — replacing the bare TemporalModel construction. - Extend the history seam assertions to every testable scenario: react_agent (get_weather + invoke_tool), filesystem_backend (backend_op >= 2), streaming (invoke_model_streaming, no invoke_model), human_in_the_loop (invoke_tool after resume). - HITL README: note that a production loop would re-check __interrupt__ after each resume. * Account for the merged-but-unreleased plugin (temporalio/sdk-python#1644) The plugin is on sdk-python main and ships as the temporalio[deepagents] extra in the next release (>= 1.31.0); it is not on PyPI yet. Point the interim install at sdk-python main (the per-plugin overlay path retired at merge), name the real post-release group entry in the pyproject note, and drop the plugin-ordering claim from langsmith_tracing — registration order does not matter. Suite verified against merged main: 8 passed. * Complete the main merge: adopt setuptools packaging, drop hatch remnants The previous merge commit was pushed with unresolved conflict markers in pyproject.toml. Resolve to main's setuptools auto-discovery (which finds deepagents_plugin without registration) and regenerate the lock. * Drop the dependency-group comment; the suite README covers the install story * Apply self-review findings: version floors, guard visibility, test rigor The review's headline: temporalio 1.31.0 already shipped on PyPI (2026-07-29) WITHOUT the deepagents extra, so every ">= 1.31.0" claim in the install story was wrong and the documented group swap would have resolved to an extra-less release. Floors now say "> 1.31.0 / the first release that ships the extra", and the group's temporalio pin rises to >= 1.31.0 to match the repo base pin. Also: the conftest collection guard now announces itself via pytest_report_header instead of silently collecting nothing, and its docstring drops the retired temporalio-contrib-deepagents dist story; the continue-as-new probe pins the first run's close event to CONTINUED_AS_NEW (a loop-in-one-run regression previously passed); the HITL suite covers the validator's invalid-decision rejection and the reject path (guarded tool never runs as an activity); scenario READMEs name the Python floor and defer to the suite setup instead of repeating it; the streaming README describes what the workflow actually drives (TemporalModel.astream).
1 parent 87c4177 commit 05070f6

55 files changed

Lines changed: 2495 additions & 16 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
# The AI SDK team owns the AI integration samples and their tests. We add
1616
# @temporalio/sdk too, so the SDK team can continue to manage repo-wide concerns.
17+
/deepagents_plugin/ @temporalio/sdk @temporalio/ai-sdk
1718
/google_adk_agents/ @temporalio/sdk @temporalio/ai-sdk
1819
/google_genai/ @temporalio/sdk @temporalio/ai-sdk
1920
/langfuse_tracing/ @temporalio/sdk @temporalio/ai-sdk
@@ -22,6 +23,7 @@
2223
/litellm_activity/ @temporalio/sdk @temporalio/ai-sdk
2324
/openai_agents/ @temporalio/sdk @temporalio/ai-sdk
2425
/strands_plugin/ @temporalio/sdk @temporalio/ai-sdk
26+
/tests/deepagents_plugin/ @temporalio/sdk @temporalio/ai-sdk
2527
/tests/google_adk_agents/ @temporalio/sdk @temporalio/ai-sdk
2628
/tests/google_genai/ @temporalio/sdk @temporalio/ai-sdk
2729
/tests/langfuse_tracing/ @temporalio/sdk @temporalio/ai-sdk

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Some examples require extra dependencies. See each sample's directory for specif
6767
* [custom_converter](custom_converter) - Use a custom payload converter to handle custom types.
6868
* [custom_decorator](custom_decorator) - Custom decorator to auto-heartbeat a long-running activity.
6969
* [custom_metric](custom_metric) - Custom metric to record the workflow type in the activity schedule to start latency.
70+
* [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.
7071
* [dsl](dsl) - DSL workflow that executes steps defined in a YAML file.
7172
* [eager_wf_start](eager_wf_start) - Run a workflow using Eager Workflow Start
7273
* [encryption](encryption) - Apply end-to-end encryption for all input/output.

deepagents_plugin/README.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Deep Agents Samples
2+
3+
These samples demonstrate the [Temporal Deep Agents plugin](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/deepagents),
4+
which makes [LangChain Deep Agents](https://github.com/langchain-ai/deepagents)
5+
durable. Build your agent with `create_deep_agent(...)` inside a
6+
`@workflow.defn` and add `DeepAgentsPlugin()` to your client — each LLM call and
7+
each I/O tool/backend operation becomes a Temporal Activity, while the agent's
8+
control loop runs (and deterministically replays) inside the Workflow.
9+
10+
> **Experimental.** The `temporalio.contrib.deepagents` plugin is experimental
11+
> and its API may change.
12+
13+
`DeepAgentsPlugin` is a **client-level** plugin: add it to `Client.connect(...)`
14+
and the SDK propagates it to any Worker built from that client. Add it on exactly
15+
one side.
16+
17+
## Samples
18+
19+
| Sample | Description |
20+
|--------|-------------|
21+
| [hello_world](hello_world) | Minimal single-shot Deep Agent; a bare `model=` string auto-routed through the model activity. Start here. |
22+
| [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 per-agent `activity_options` via `create_temporal_deep_agent`. |
23+
| [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. |
24+
| [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`. |
25+
| [filesystem_backend](filesystem_backend) | Durable real filesystem I/O by wrapping a `FilesystemBackend` in `TemporalBackend`. |
26+
| [subagents](subagents) | Durability propagates across the agent tree — sub-agent model calls become activities with no per-sub-agent wiring. |
27+
| [streaming](streaming) | Stream model chunks to external subscribers via `streaming_topic` + `WorkflowStream`, keeping the durable result identical. |
28+
| [langsmith_tracing](langsmith_tracing) | Compose `DeepAgentsPlugin` with `LangSmithPlugin` for durable execution + LLM tracing. |
29+
30+
## Prerequisites
31+
32+
> **Python ≥ 3.11 required.** `deepagents` (and therefore the plugin) does not
33+
> support older interpreters. On Python 3.10 the `deepagents` dependency group
34+
> resolves to nothing, so `uv sync` silently installs none of the dependencies
35+
> below.
36+
37+
1. Install dependencies:
38+
39+
```bash
40+
uv sync --group deepagents
41+
```
42+
43+
> The Deep Agents plugin ships as the `temporalio[deepagents]` extra. It
44+
> is merged to `sdk-python` `main` but the current PyPI release (1.31.0)
45+
> predates the merge and does not carry the extra, so the `deepagents`
46+
> group above does not include it yet. Until a release ships the extra
47+
> (> 1.31.0), install it from main:
48+
>
49+
> ```bash
50+
> uv pip install "temporalio[deepagents] @ git+https://github.com/temporalio/sdk-python.git"
51+
> ```
52+
>
53+
> This builds the SDK from source (including its Rust core), so expect a
54+
> few minutes on first install. Once a release with the extra is on PyPI
55+
> this step goes away: `temporalio[deepagents]` joins the `deepagents`
56+
> group and a plain `uv sync --group deepagents` is all you need.
57+
58+
2. Configure a model provider. The samples use
59+
`anthropic:claude-sonnet-4-5`, which needs an Anthropic API key:
60+
61+
```bash
62+
export ANTHROPIC_API_KEY=...
63+
```
64+
65+
To use a different provider, change the `model=` string in the sample's
66+
`workflow.py` and set that provider's credentials (the plugin resolves the
67+
model worker-side via LangChain's `init_chat_model`).
68+
69+
3. Start a [Temporal dev server](https://docs.temporal.io/cli#start-dev-server):
70+
71+
```bash
72+
temporal server start-dev
73+
```
74+
75+
## Running a Sample
76+
77+
> **Use `uv run --no-sync`.** Because the plugin is installed out-of-band
78+
> from sdk-python main (see Prerequisites) and is not yet in any dependency
79+
> group, a bare `uv run` or `uv sync` re-syncs the environment to the lockfile
80+
> first and uninstalls it. `--no-sync` runs against the environment as-is.
81+
> (Once a released `temporalio[deepagents]` joins the `deepagents` group, the
82+
> flag becomes unnecessary.)
83+
84+
Most samples have two scripts. Start the Worker first, then the Workflow starter
85+
in a separate terminal:
86+
87+
```bash
88+
# Terminal 1: start the Worker
89+
uv run --no-sync deepagents_plugin/<sample>/run_worker.py
90+
91+
# Terminal 2: start the Workflow
92+
uv run --no-sync deepagents_plugin/<sample>/run_workflow.py
93+
```
94+
95+
For example, to run the hello world sample:
96+
97+
```bash
98+
# Terminal 1
99+
uv run --no-sync deepagents_plugin/hello_world/run_worker.py
100+
101+
# Terminal 2
102+
uv run --no-sync deepagents_plugin/hello_world/run_workflow.py
103+
```
104+
105+
The `langsmith_tracing` sample instead bundles the worker and starter into a
106+
single driver:
107+
108+
```bash
109+
uv run --no-sync deepagents_plugin/langsmith_tracing/main.py
110+
```
111+
112+
## Key Features Demonstrated
113+
114+
- **Durable model invocation** — every LLM call runs in an `invoke_model`
115+
activity with configurable timeouts and retries; a bare `model=` string is
116+
auto-routed, or use `create_temporal_deep_agent(..., activity_options=...)`
117+
to scope model-call options per agent (recommended).
118+
- **Explicit Workflow-vs-Activity tool choice**`activity_as_tool`,
119+
`tool_as_activity`, and `TemporalBackend` move I/O out of workflow code.
120+
- **Human-in-the-loop** — the native LangGraph `interrupt_on` return value
121+
mapped to a Temporal Query and Update.
122+
- **Long-lived agents**`run_deep_agent(...)` carries messages and the result
123+
cache across server-suggested (or explicitly thresholded) continue-as-new.
124+
- **Sub-agent durability** — sub-agents inherit the durable model object with no
125+
extra wiring.
126+
- **Streaming** — forward model chunks to external subscribers while keeping the
127+
durable result unchanged.
128+
- **Observability** — compose with `LangSmithPlugin` for tracing.
129+
130+
## Related
131+
132+
- [Temporal Deep Agents plugin](https://github.com/temporalio/sdk-python/tree/main/temporalio/contrib/deepagents)
133+
- [LangChain Deep Agents](https://github.com/langchain-ai/deepagents)
134+
- [langgraph_plugin](../langgraph_plugin) — for agents built directly as LangGraph graphs

deepagents_plugin/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Temporal Deep Agents plugin samples."""
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Continue as New
2+
3+
A long-running research agent whose conversation could outgrow Temporal's
4+
workflow-history limit. `run_deep_agent(agent, input, state_snapshot=...)` keeps
5+
the run bounded: once a turn ends with pending todos and the server recommends
6+
continuing (`workflow.info().is_continue_as_new_suggested()` — the default and
7+
recommended mode, which accounts for both history length and size), it snapshots
8+
the accumulated messages **and** the model/tool result cache and continues into
9+
a fresh run. Completed model/tool calls are reused from the carried cache rather
10+
than re-run. To trigger on a fixed history-event count instead, pass an explicit
11+
`continue_as_new_after=N`.
12+
13+
The `@workflow.run` signature is `run(self, input, state_snapshot=None)`, where
14+
`input` is the messages mapping and `state_snapshot` is how `run_deep_agent`
15+
threads carried state into the continued run. On a continue-as-new the workflow
16+
is re-invoked with `args=[input, snapshot]`, so `input` must be passed straight
17+
into `run_deep_agent` — re-wrapping it would nest a dict where a message is
18+
expected and corrupt the carried conversation. Durability rides on the default
19+
in-workflow `InMemorySaver` (rehydrated by replay); a database-backed
20+
checkpointer would do I/O from workflow code and is not replay-safe.
21+
22+
## What This Sample Demonstrates
23+
24+
- `run_deep_agent(agent, input, state_snapshot=...)` in the default
25+
server-suggested mode (with `continue_as_new_after=N` as the explicit override)
26+
- The `run(self, input, state_snapshot=None)` continue-as-new contract
27+
- Carrying both messages and the result cache across continue-as-new
28+
29+
## Running the Sample
30+
31+
Prerequisites: Python >= 3.11 with the [suite setup](../README.md#prerequisites)
32+
applied (interim plugin install), an `ANTHROPIC_API_KEY` in your
33+
environment, and a running Temporal dev server (`temporal server start-dev`).
34+
35+
> The experimental plugin is not in the `deepagents` group — install it as shown
36+
> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or
37+
> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it.
38+
39+
```bash
40+
# Terminal 1
41+
uv run --no-sync deepagents_plugin/continue_as_new/run_worker.py
42+
43+
# Terminal 2
44+
uv run --no-sync deepagents_plugin/continue_as_new/run_workflow.py
45+
```
46+
47+
## Files
48+
49+
| File | Description |
50+
|------|-------------|
51+
| `workflow.py` | `LongResearchAgent` driven by `run_deep_agent` |
52+
| `run_worker.py` | Adds `DeepAgentsPlugin`, starts the worker |
53+
| `run_workflow.py` | Executes the workflow and prints the result |

deepagents_plugin/continue_as_new/__init__.py

Whitespace-only changes.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Worker for the continue-as-new sample."""
2+
3+
import asyncio
4+
import os
5+
6+
from temporalio.client import Client
7+
from temporalio.contrib.deepagents import DeepAgentsPlugin
8+
from temporalio.worker import Worker
9+
10+
from deepagents_plugin.continue_as_new.workflow import LongResearchAgent
11+
12+
13+
async def main() -> None:
14+
client = await Client.connect(
15+
os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"),
16+
plugins=[DeepAgentsPlugin()],
17+
)
18+
19+
worker = Worker(
20+
client,
21+
task_queue="deepagents-continue-as-new",
22+
workflows=[LongResearchAgent],
23+
)
24+
print("Worker started. Ctrl+C to exit.")
25+
await worker.run()
26+
27+
28+
if __name__ == "__main__":
29+
asyncio.run(main())
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Start the long-running research agent workflow."""
2+
3+
import asyncio
4+
import os
5+
6+
from temporalio.client import Client
7+
8+
from deepagents_plugin.continue_as_new.workflow import LongResearchAgent
9+
10+
11+
async def main() -> None:
12+
client = await Client.connect(os.environ.get("TEMPORAL_ADDRESS", "localhost:7233"))
13+
14+
result = await client.execute_workflow(
15+
LongResearchAgent.run,
16+
# The workflow's first arg is the messages mapping (run_deep_agent's
17+
# continue-as-new contract), not a bare question string.
18+
{
19+
"messages": [
20+
{
21+
"role": "user",
22+
"content": (
23+
"Research the tradeoffs between Raft and Paxos and "
24+
"summarize them."
25+
),
26+
}
27+
]
28+
},
29+
id="deepagents-continue-as-new",
30+
task_queue="deepagents-continue-as-new",
31+
)
32+
33+
print(f"Result: {result}")
34+
35+
36+
if __name__ == "__main__":
37+
asyncio.run(main())
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Long-running research agent that carries state across continue-as-new.
2+
3+
A long conversation would bloat workflow history until it hits Temporal's limit.
4+
``run_deep_agent(agent, input, state_snapshot=...)`` solves this: once a turn
5+
finishes with pending work and the server recommends continuing
6+
(``workflow.info().is_continue_as_new_suggested()`` — the default and
7+
recommended mode, accounting for both history length and size), it snapshots the
8+
accumulated messages **and** the model/tool result cache and continues into a
9+
fresh run — so completed model/tool calls are reused, not re-run, after the
10+
continue-as-new. Pass ``continue_as_new_after=N`` instead to trigger on a fixed
11+
history-event count.
12+
13+
The contract ``run_deep_agent`` requires is that the ``@workflow.run`` method
14+
accepts the carried state, i.e. its signature is
15+
``run(self, input, state_snapshot=None)`` where ``input`` is the messages
16+
mapping. On a continue-as-new, ``run_deep_agent`` re-invokes the workflow with
17+
``args=[input, snapshot]``, so ``input`` must be passed straight through — not
18+
re-wrapped — or the carried conversation is corrupted. Only an in-workflow
19+
``InMemorySaver`` (the default) is replay-safe; a durable checkpointer would do
20+
I/O from workflow code.
21+
"""
22+
23+
# @@@SNIPSTART python-deepagents-continue-as-new-workflow
24+
from typing import Any
25+
26+
from deepagents import create_deep_agent
27+
from temporalio import workflow
28+
from temporalio.contrib.deepagents import run_deep_agent
29+
30+
31+
@workflow.defn
32+
class LongResearchAgent:
33+
@workflow.run
34+
async def run(
35+
self, input: dict[str, Any], state_snapshot: dict | None = None
36+
) -> str:
37+
agent = create_deep_agent(
38+
model="anthropic:claude-sonnet-4-5",
39+
system_prompt=(
40+
"You are a research agent. Break large tasks into todos and work "
41+
"through them until the research is complete."
42+
),
43+
)
44+
result = await run_deep_agent(
45+
agent,
46+
# ``input`` is the messages mapping. Pass it through unchanged: on a
47+
# continue-as-new, run_deep_agent re-invokes this method with the
48+
# carried input as its first arg, so re-wrapping it here would nest a
49+
# dict where a message is expected and corrupt the conversation.
50+
input,
51+
# No threshold: continue-as-new fires when the agent still has
52+
# pending todos and the server suggests continuing — the recommended
53+
# mode. Pass continue_as_new_after=N to use a fixed event count.
54+
state_snapshot=state_snapshot,
55+
)
56+
return result["messages"][-1].content
57+
58+
59+
# @@@SNIPEND
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Filesystem Backend
2+
3+
Give a Deep Agent durable, real filesystem access. The agent's built-in file
4+
tools (`write_file`, `read_file`, `ls`, …) delegate to a *backend*. Wrapping a
5+
`FilesystemBackend` with `TemporalBackend(inner, activity_options=...)` routes
6+
each file operation through a `deepagents.backend_op` activity, so real disk I/O
7+
happens in an activity worker instead of in workflow code.
8+
9+
Contrast this with the default `StateBackend`, whose "files" live in agent state
10+
— that is pure workflow state and correctly stays in the workflow with no
11+
wrapping. `TemporalBackend` is only for backends that do real I/O.
12+
13+
The scratch directory (`root_dir`) is chosen client-side and passed in as a
14+
workflow argument, so the workflow never reads the environment or the disk
15+
directly.
16+
17+
## What This Sample Demonstrates
18+
19+
- `TemporalBackend` wrapping a real-I/O `FilesystemBackend`
20+
- The agent's built-in file tools running their I/O as `backend_op` activities
21+
- Keeping the workflow deterministic by passing `root_dir` in as an argument
22+
23+
## Running the Sample
24+
25+
Prerequisites: Python >= 3.11 with the [suite setup](../README.md#prerequisites)
26+
applied (interim plugin install), an `ANTHROPIC_API_KEY` in your
27+
environment, and a running Temporal dev server (`temporal server start-dev`).
28+
29+
> The experimental plugin is not in the `deepagents` group — install it as shown
30+
> in the [suite README](../README.md#prerequisites) and run with `--no-sync`, or
31+
> a bare `uv run`/`uv sync` re-syncs the environment and uninstalls it.
32+
33+
```bash
34+
# Terminal 1
35+
uv run --no-sync deepagents_plugin/filesystem_backend/run_worker.py
36+
37+
# Terminal 2
38+
uv run --no-sync deepagents_plugin/filesystem_backend/run_workflow.py
39+
```
40+
41+
By default the starter creates a temporary scratch directory; set
42+
`DEEPAGENTS_WORKDIR` to point the agent at a directory of your choice.
43+
44+
## Files
45+
46+
| File | Description |
47+
|------|-------------|
48+
| `workflow.py` | `FilesystemAgent` wrapping a `FilesystemBackend` in `TemporalBackend` |
49+
| `run_worker.py` | Adds `DeepAgentsPlugin`, starts the worker |
50+
| `run_workflow.py` | Chooses a scratch dir, executes the workflow, prints the result |

0 commit comments

Comments
 (0)