From 68ab699c074a7e4589a8f9eea5c0ec60b4599df8 Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 11:15:09 -0500 Subject: [PATCH 1/7] Add Deep Agents contrib plugin temporalio.contrib.deepagents makes LangChain Deep Agents durable: unmodified create_deep_agent(...).ainvoke(...) code runs inside a workflow with plugins=[DeepAgentsPlugin()], routing every model turn, I/O tool call, and backend file/shell op through Temporal activities while the agent loop replays deterministically. Includes explicit per-tool workflow-vs-activity choice (activity_as_tool / tool_as_activity), TemporalBackend interception of the full backend protocol (sync + async + execute) with typed protocol-dataclass round-trip, continue-as-new state carry via run_deep_agent, streaming through workflow_streams, and native LangGraph interrupt/resume for human-in-the-loop. Ships as the temporalio[deepagents] extra (Python >= 3.11, matching deepagents' own floor). --- pyproject.toml | 9 + temporalio/contrib/deepagents/README.md | 180 ++++++++ temporalio/contrib/deepagents/__init__.py | 62 +++ temporalio/contrib/deepagents/_activity.py | 354 +++++++++++++++ temporalio/contrib/deepagents/_model.py | 368 ++++++++++++++++ temporalio/contrib/deepagents/_plugin.py | 284 ++++++++++++ temporalio/contrib/deepagents/_serde.py | 410 ++++++++++++++++++ temporalio/contrib/deepagents/_tools.py | 403 +++++++++++++++++ temporalio/contrib/deepagents/py.typed | 0 temporalio/contrib/deepagents/testing.py | 139 ++++++ temporalio/contrib/deepagents/workflow.py | 252 +++++++++++ tests/contrib/deepagents/__init__.py | 0 tests/contrib/deepagents/helpers.py | 16 + tests/contrib/deepagents/test_backends.py | 212 +++++++++ tests/contrib/deepagents/test_checkpointer.py | 93 ++++ .../deepagents/test_continue_as_new.py | 96 ++++ tests/contrib/deepagents/test_failures.py | 117 +++++ tests/contrib/deepagents/test_hitl.py | 132 ++++++ .../contrib/deepagents/test_model_activity.py | 101 +++++ tests/contrib/deepagents/test_native_e2e.py | 136 ++++++ tests/contrib/deepagents/test_readme.py | 36 ++ tests/contrib/deepagents/test_replay.py | 63 +++ tests/contrib/deepagents/test_side_effects.py | 72 +++ tests/contrib/deepagents/test_streaming.py | 105 +++++ tests/contrib/deepagents/test_subagents.py | 84 ++++ tests/contrib/deepagents/test_tools.py | 120 +++++ uv.lock | 162 ++++++- 27 files changed, 3999 insertions(+), 7 deletions(-) create mode 100644 temporalio/contrib/deepagents/README.md create mode 100644 temporalio/contrib/deepagents/__init__.py create mode 100644 temporalio/contrib/deepagents/_activity.py create mode 100644 temporalio/contrib/deepagents/_model.py create mode 100644 temporalio/contrib/deepagents/_plugin.py create mode 100644 temporalio/contrib/deepagents/_serde.py create mode 100644 temporalio/contrib/deepagents/_tools.py create mode 100644 temporalio/contrib/deepagents/py.typed create mode 100644 temporalio/contrib/deepagents/testing.py create mode 100644 temporalio/contrib/deepagents/workflow.py create mode 100644 tests/contrib/deepagents/__init__.py create mode 100644 tests/contrib/deepagents/helpers.py create mode 100644 tests/contrib/deepagents/test_backends.py create mode 100644 tests/contrib/deepagents/test_checkpointer.py create mode 100644 tests/contrib/deepagents/test_continue_as_new.py create mode 100644 tests/contrib/deepagents/test_failures.py create mode 100644 tests/contrib/deepagents/test_hitl.py create mode 100644 tests/contrib/deepagents/test_model_activity.py create mode 100644 tests/contrib/deepagents/test_native_e2e.py create mode 100644 tests/contrib/deepagents/test_readme.py create mode 100644 tests/contrib/deepagents/test_replay.py create mode 100644 tests/contrib/deepagents/test_side_effects.py create mode 100644 tests/contrib/deepagents/test_streaming.py create mode 100644 tests/contrib/deepagents/test_subagents.py create mode 100644 tests/contrib/deepagents/test_tools.py diff --git a/pyproject.toml b/pyproject.toml index bcad8903c..8a2c7bc64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,11 @@ openai-agents = ["openai-agents>=0.17.5", "mcp>=1.9.4, <2"] google-adk = ["google-adk>=2.2.0,<3"] langgraph = ["langgraph>=1.1.0"] langsmith = ["langsmith>=0.7.34,<0.9"] +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'", +] lambda-worker-otel = [ "opentelemetry-api>=1.11.1,<2", "opentelemetry-sdk>=1.11.1,<2", @@ -81,6 +86,10 @@ dev = [ "moto[s3,server]>=5", "langgraph>=1.1.0", "langsmith>=0.7.34,<0.9", + "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'", "setuptools<82", "opentelemetry-exporter-otlp-proto-grpc>=1.11.1,<2", "opentelemetry-semantic-conventions>=0.40b0,<1", diff --git a/temporalio/contrib/deepagents/README.md b/temporalio/contrib/deepagents/README.md new file mode 100644 index 000000000..86ffba852 --- /dev/null +++ b/temporalio/contrib/deepagents/README.md @@ -0,0 +1,180 @@ +# DeepAgentsPlugin — Temporal plugin for LangChain Deep Agents + +Make a [Deep Agent](https://github.com/langchain-ai/deepagents) durable by adding +one plugin. Build your agent with `create_deep_agent(...)` inside a +`@workflow.defn`, add `plugins=[DeepAgentsPlugin(...)]` to your Client (or +Worker), and each LLM call and each I/O tool call becomes a Temporal Activity — +while the agent's control loop runs, and deterministically replays, inside the +Workflow. + +The code you already wrote against `deepagents` does not change: sub-agents, +planning/todo state, the filesystem middleware, human-in-the-loop interrupts, and +`agent.ainvoke(...)` all keep working. You get crash-durability, resumable +human-in-the-loop, and bounded history on top. + +> This package is experimental and may change in future versions. + +## Install + +```bash +pip install "temporalio[deepagents]" +``` + +Requires Python ≥ 3.11 (the same floor `deepagents` sets). + +## Hello world + +```python +import asyncio +from datetime import timedelta + +from temporalio import workflow +from temporalio.client import Client +from temporalio.worker import Worker + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + +from temporalio.contrib.deepagents import DeepAgentsPlugin + + +@workflow.defn +class ResearchAgent: + @workflow.run + async def run(self, question: str) -> str: + # Vanilla deepagents code. The plugin routes the model call through an + # activity automatically because `model=` is a name string. + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + system_prompt="You are a careful research assistant.", + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": question}]} + ) + return result["messages"][-1].content + + +async def main() -> None: + # API keys live on the worker via the model provider, never in workflow + # inputs or history. The default provider is LangChain's init_chat_model. + plugin = DeepAgentsPlugin( + model_activity_options={"start_to_close_timeout": timedelta(minutes=5)}, + ) + # Add the plugin on ONE side. The SDK propagates a Client plugin to any + # Worker built from that Client, so the Worker below inherits it. + client = await Client.connect("localhost:7233", plugins=[plugin]) + worker = Worker( + client, + task_queue="deepagents-task-queue", + workflows=[ResearchAgent], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## What this plugin gives you + +- **Drop-in durability.** `create_deep_agent(...).ainvoke(...)` runs unchanged + inside a Workflow. The loop replays deterministically; every nondeterministic + step (LLM, I/O tool, real filesystem/shell op) is an Activity. +- **One LLM call per Activity.** The Workflow ships only the model *name*; the + worker's `model_provider` builds the real client. Temporal owns retries and + timeouts (LLM-SDK retries are disabled), configurable per model via + `model_activity_options`. +- **Existing activities as tools.** Already have `@activity.defn` functions? + `activity_as_tool(my_activity, start_to_close_timeout=...)` exposes one to the + agent without re-declaring it. +- **Explicit Workflow-vs-Activity choice per tool.** `tool_as_activity(tool, ...)` + moves a LangChain tool's execution into an Activity. An unwrapped, non-builtin + tool runs in-workflow and the plugin warns at construction so that choice is + never silent. Deep Agents' pure built-ins (`write_todos`, state-backed file + tools) stay in-workflow by design. +- **Real backends via activities.** Wrap a `FilesystemBackend` / + `LocalShellBackend` / `StoreBackend` with `TemporalBackend(inner, ...)` so each + file/shell op is a durable Activity. State-only backends need no wrapping. +- **Sub-agents inherit durability.** Because sub-agents inherit the parent's + `model` object and tools, substituting them once propagates to the whole agent + tree — no per-sub-agent wiring. +- **Human-in-the-loop.** With `interrupt_on=...` (and a checkpointer), the agent + pauses and `ainvoke(...)` returns the pending approval under the SDK-native + `__interrupt__` key directly in your workflow; expose it via a Query and resume + with a Workflow Update carrying `Command(resume={"decisions": [...]})`. No shim + exception — the native LangGraph resume protocol is used as-is. +- **Streaming.** Set `streaming_topic="..."` to stream chat-completion chunk + batches out of the model Activity to external subscribers; the aggregated final + message is returned to the workflow so the durable result is identical to the + non-streaming path. + +## Continue-as-new: what carries and what does not + +Long conversations bloat workflow history. `run_deep_agent(agent, input, +continue_as_new_after=N)` snapshots state and continues into a fresh run once +history passes `N` events and the agent still has pending todos: + +```python +from temporalio import workflow +from temporalio.contrib.deepagents import run_deep_agent + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + + +@workflow.defn +class LongResearchAgent: + @workflow.run + async def run(self, input: dict, state_snapshot: dict | None = None) -> dict: + agent = create_deep_agent(model="anthropic:claude-sonnet-4-5") + return await run_deep_agent( + agent, + input, + continue_as_new_after=10_000, + state_snapshot=state_snapshot, + ) +``` + +- **Carries forward:** the accumulated messages and the model/tool result cache + (so an LLM/tool call completed before the continue-as-new is *not* re-run + after it). Your `@workflow.run` must accept `state_snapshot=None` as shown. +- **Does not carry forward:** anything held only in an in-memory checkpointer's + own structures beyond the messages/todos snapshot. The default in-workflow + `InMemorySaver` is rehydrated for free by deterministic replay; a durable + checkpointer that does its own I/O is not replay-safe from inside a workflow, + and the plugin warns if you pass one — prefer the snapshot + continue-as-new + path above. + +## Runtime behavior + +While a worker built with this plugin is running, the plugin wraps +`deepagents.create_deep_agent` so a bare `model="provider:name"` string is +auto-routed through an Activity. The wrapper only rewrites arguments when called +*inside a workflow*, so importing `deepagents` on a plain client or activity +worker is unaffected, and the original function is restored when the worker +stops. If you would rather be explicit, pass `TemporalModel("provider:name")` +yourself. + +## Composing with other plugins + +This plugin carries no tracing context of its own. For observability, compose it +with `temporalio.contrib.langsmith` or `temporalio.contrib.opentelemetry`, +registered *before* this plugin: + +```python +from temporalio.client import Client +from temporalio.contrib.deepagents import DeepAgentsPlugin + + +async def connect(): + return await Client.connect( + "localhost:7233", + plugins=[ + # ObservabilityPlugin(...), # register observability first + DeepAgentsPlugin(), + ], + ) +``` + +For agents built directly as LangGraph graphs (rather than a compiled Deep +Agent), see `temporalio.contrib.langgraph`. diff --git a/temporalio/contrib/deepagents/__init__.py b/temporalio/contrib/deepagents/__init__.py new file mode 100644 index 000000000..681dad292 --- /dev/null +++ b/temporalio/contrib/deepagents/__init__.py @@ -0,0 +1,62 @@ +"""Temporal plugin for LangChain Deep Agents. + +Make an existing Deep Agent durable by adding one plugin: build your agent with +``create_deep_agent(...)`` inside a ``@workflow.defn`` and add +``plugins=[DeepAgentsPlugin(...)]`` to your Client or Worker. Each LLM call and +each I/O tool call becomes a Temporal activity, while the agent's control loop +runs — and deterministically replays — inside the workflow. + +.. warning:: + This package is experimental and may change in future versions. + +The public names are imported lazily so ``import temporalio.contrib.deepagents`` +succeeds before LangChain is installed; touching a name that needs LangChain +imports it on first access. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +__all__ = [ + "DeepAgentsPlugin", + "TemporalModel", + "TemporalBackend", + "activity_as_tool", + "tool_as_activity", + "run_deep_agent", + "DeepAgentsWorkflowError", +] + +if TYPE_CHECKING: + from temporalio.contrib.deepagents._model import TemporalModel + from temporalio.contrib.deepagents._plugin import DeepAgentsPlugin + from temporalio.contrib.deepagents._tools import ( + TemporalBackend, + activity_as_tool, + tool_as_activity, + ) + from temporalio.contrib.deepagents.workflow import ( + DeepAgentsWorkflowError, + run_deep_agent, + ) + + +def __getattr__(name: str) -> object: + if name == "DeepAgentsPlugin": + from temporalio.contrib.deepagents._plugin import DeepAgentsPlugin + + return DeepAgentsPlugin + if name == "TemporalModel": + from temporalio.contrib.deepagents._model import TemporalModel + + return TemporalModel + if name in ("TemporalBackend", "activity_as_tool", "tool_as_activity"): + from temporalio.contrib.deepagents import _tools + + return getattr(_tools, name) + if name in ("DeepAgentsWorkflowError", "run_deep_agent"): + from temporalio.contrib.deepagents import workflow + + return getattr(workflow, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/temporalio/contrib/deepagents/_activity.py b/temporalio/contrib/deepagents/_activity.py new file mode 100644 index 000000000..3e33986ad --- /dev/null +++ b/temporalio/contrib/deepagents/_activity.py @@ -0,0 +1,354 @@ +"""The activities that carry every nondeterministic Deep Agents operation. + +The Deep Agents control loop runs *inside* the workflow; the operations that +must not run there — talking to an LLM, executing a tool that does real I/O, or +touching a real filesystem / shell backend — are moved out to these activities. + +Each activity is a method on :class:`DeepAgentActivities` so the worker-only +dependencies (the ``model_provider`` that builds real chat models from a name, +the streaming batch interval) can be captured on the instance rather than +smuggled through activity inputs. API keys therefore live on the worker, never +in a workflow input or in history. + +Every method: + +* takes a single serializable dataclass in and returns a single dataclass out + (LangChain objects travel as their ``dumpd`` JSON form via + :mod:`temporalio.contrib.deepagents._serde`); +* translates the LLM SDK's HTTP error into Temporal's retry contract so a 429 + honors the upstream ``retry-after`` instead of hammering it; +* heartbeats on a background task so a slow (thinking-mode / long-context) call + is not mistaken for a stuck worker. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +from datetime import timedelta +from functools import wraps +from typing import Any, Callable + +from temporalio import activity +from temporalio.contrib.deepagents import _serde +from temporalio.exceptions import ApplicationError + +# Activity type names. The workflow dispatches by these strings, so the +# in-workflow model / tool stubs never import the activity class itself. +INVOKE_MODEL = "deepagents.invoke_model" +INVOKE_MODEL_STREAMING = "deepagents.invoke_model_streaming" +INVOKE_TOOL = "deepagents.invoke_tool" +BACKEND_OP = "deepagents.backend_op" + + +# --------------------------------------------------------------------------- +# Boundary payloads +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class ModelActivityInput: + """A single LLM request. + + ``model_name`` is resolved to a real model by the worker's + ``model_provider``; the workflow never ships credentials. + """ + + model_name: str + messages: list[Any] + """Messages in ``langchain_core.load.dumpd`` form.""" + tool_schemas: list[dict[str, Any]] = dataclasses.field(default_factory=list) + """OpenAI-format tool advertisements (name + description + argument schema).""" + bind_kwargs: dict[str, Any] = dataclasses.field(default_factory=dict) + config: dict[str, Any] = dataclasses.field(default_factory=dict) + """A stripped ``RunnableConfig`` (see :func:`_serde.strip_runnable_config`).""" + streaming_topic: str | None = None + + +@dataclasses.dataclass +class ModelActivityOutput: + """The model's reply. + + ``message`` is an ``AIMessage`` in ``dumpd`` form, carrying tool calls, + usage, and response metadata. + """ + + message: Any + + +@dataclasses.dataclass +class ToolActivityInput: + """One tool execution routed to an activity.""" + + tool_name: str + tool_call_id: str + args: dict[str, Any] + config: dict[str, Any] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass +class ToolActivityOutput: + """A tool result as a ``ToolMessage`` in ``dumpd`` form.""" + + message: Any + + +@dataclasses.dataclass +class BackendOpInput: + """A single filesystem / shell / store operation for a wrapped backend.""" + + backend_ref: str + """Key identifying which registered backend to act on.""" + op: str + """Backend method name, e.g. ``ls`` / ``read_file`` / ``write_file`` / ``execute``.""" + args: list[Any] = dataclasses.field(default_factory=list) + kwargs: dict[str, Any] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass +class BackendOpOutput: + """The backend operation's return value. + + Deepagents protocol dataclasses (``WriteResult`` / ``ReadResult`` / …) + ride in the tagged form produced by ``_serde.dump_backend_result`` so the + in-workflow stub can rebuild the real type; plain JSON values pass + through unchanged. + """ + + result: Any + + +# --------------------------------------------------------------------------- +# Heartbeating + error translation +# --------------------------------------------------------------------------- + + +def _auto_heartbeater(fn: Callable) -> Callable: + """Heartbeat at half the configured ``heartbeat_timeout`` while ``fn`` runs. + + Long LLM calls (thinking mode, long context, streaming accumulation) can run + well past a scheduler's patience; without a heartbeat Temporal would cancel + them and surface a ``HeartbeatTimeoutError`` instead of the real problem. + """ + + @wraps(fn) + async def wrapped(*args: Any, **kwargs: Any) -> Any: + heartbeat_timeout = activity.info().heartbeat_timeout + beat_task: asyncio.Task | None = None + if heartbeat_timeout: + interval = heartbeat_timeout.total_seconds() / 2 + + async def beat() -> None: + while True: + activity.heartbeat() + await asyncio.sleep(interval) + + beat_task = asyncio.create_task(beat()) + try: + return await fn(*args, **kwargs) + finally: + if beat_task is not None: + beat_task.cancel() + + return wrapped + + +def _translate_api_error(exc: Exception) -> ApplicationError | None: + """Map an LLM SDK HTTP error onto Temporal's retry contract. + + Works by duck typing so neither ``openai`` nor ``anthropic`` needs to be + imported here: both expose ``status_code`` and ``response.headers``. Returns + ``None`` when ``exc`` is not a recognizable HTTP status error, so the caller + can fall through to its generic handling. + """ + status = getattr(exc, "status_code", None) + if status is None: + return None + headers: dict[str, Any] = {} + response = getattr(exc, "response", None) + if response is not None: + headers = dict(getattr(response, "headers", {}) or {}) + # Case-insensitive header access. + lower = {str(k).lower(): v for k, v in headers.items()} + + retryable = status in (408, 409, 429) or 500 <= status < 600 + should_retry = lower.get("x-should-retry") + if should_retry == "false": + retryable = False + elif should_retry == "true": + retryable = True + + delay_ms = lower.get("retry-after-ms") + retry_after = lower.get("retry-after") + next_delay: timedelta | None = None + try: + if delay_ms is not None: + next_delay = timedelta(milliseconds=int(delay_ms)) + elif retry_after is not None: + next_delay = timedelta(seconds=int(retry_after)) + except (TypeError, ValueError): + next_delay = None + + return ApplicationError( + str(exc), + type=type(exc).__name__, + non_retryable=not retryable, + next_retry_delay=next_delay, + ) + + +# --------------------------------------------------------------------------- +# The activities +# --------------------------------------------------------------------------- + + +def _default_model_provider(model_name: str) -> Any: + """Build a chat model from a name string with LLM-SDK retries disabled. + + Temporal owns retries; the model client must not also retry, or a single + logical attempt fans out into nested retry storms that Temporal can neither + see nor bound. + """ + from langchain.chat_models import init_chat_model + + return init_chat_model(model_name, max_retries=0) + + +class DeepAgentActivities: + """Holds the worker-side dependencies and exposes the four activities. + + An instance is created by :class:`~temporalio.contrib.deepagents.DeepAgentsPlugin` + and its bound methods are registered on the worker. + """ + + def __init__( + self, + *, + model_provider: Callable[[str], Any] | None = None, + streaming_batch_interval: timedelta = timedelta(milliseconds=100), + ) -> None: + """Store the worker-side model provider + streaming configuration.""" + self._model_provider = model_provider or _default_model_provider + self._streaming_batch_interval = streaming_batch_interval + + def _build_bound_model(self, input: ModelActivityInput) -> Any: + model = self._model_provider(input.model_name) + if input.bind_kwargs: + model = model.bind(**input.bind_kwargs) + if input.tool_schemas: + model = model.bind_tools(input.tool_schemas) + return model + + @activity.defn(name=INVOKE_MODEL) + @_auto_heartbeater + async def invoke_model(self, input: ModelActivityInput) -> ModelActivityOutput: + """Run exactly one LLM call and return the resulting ``AIMessage``.""" + messages = _serde.load_messages(input.messages) + config = _serde.rebuild_runnable_config(input.config) + model = self._build_bound_model(input) + try: + message = await model.ainvoke(messages, config=config) + except Exception as exc: + translated = _translate_api_error(exc) + if translated is not None: + activity.logger.warning( + "Model call failed with an HTTP status error", exc_info=True + ) + raise translated from exc + raise + return ModelActivityOutput(message=_serde.dump_object(message)) + + @activity.defn(name=INVOKE_MODEL_STREAMING) + @_auto_heartbeater + async def invoke_model_streaming( + self, input: ModelActivityInput + ) -> ModelActivityOutput: + """Stream one LLM call, publishing chunk batches to ``streaming_topic``. + + Token-level deltas are coalesced at ``streaming_batch_interval`` and + pushed to external subscribers via the shared workflow-streams topic; the + aggregated final ``AIMessage`` is returned to the workflow so the + durable result is identical to the non-streaming path. + """ + from temporalio.contrib.workflow_streams import WorkflowStreamClient + + messages = _serde.load_messages(input.messages) + config = _serde.rebuild_runnable_config(input.config) + model = self._build_bound_model(input) + + final: Any = None + try: + async with WorkflowStreamClient.from_within_activity( + batch_interval=self._streaming_batch_interval + ) as client: + topic = ( + client.topic(input.streaming_topic) + if input.streaming_topic + else None + ) + async for chunk in model.astream(messages, config=config): + if topic is not None: + topic.publish(_serde.dump_object(chunk)) + final = chunk if final is None else final + chunk + except Exception as exc: + translated = _translate_api_error(exc) + if translated is not None: + activity.logger.warning( + "Streaming model call failed with an HTTP status error", + exc_info=True, + ) + raise translated from exc + raise + return ModelActivityOutput(message=_serde.dump_object(final)) + + @activity.defn(name=INVOKE_TOOL) + @_auto_heartbeater + async def invoke_tool(self, input: ToolActivityInput) -> ToolActivityOutput: + """Execute one registered tool and return its ``ToolMessage``.""" + from temporalio.contrib.deepagents._tools import get_registered_tool + + tool = get_registered_tool(input.tool_name) + if tool is None: + raise ApplicationError( + f"Tool {input.tool_name!r} is not registered on this worker. " + f"Wrap it with tool_as_activity(...) or activity_as_tool(...).", + type="DeepAgentsUnknownTool", + non_retryable=True, + ) + config = _serde.rebuild_runnable_config(input.config) + tool_call = { + "name": input.tool_name, + "args": input.args, + "id": input.tool_call_id, + "type": "tool_call", + } + message = await tool.ainvoke(tool_call, config=config) + return ToolActivityOutput(message=_serde.dump_object(message)) + + @activity.defn(name=BACKEND_OP) + @_auto_heartbeater + async def backend_op(self, input: BackendOpInput) -> BackendOpOutput: + """Run one operation against a registered (real-I/O) backend.""" + from temporalio.contrib.deepagents._tools import registered_backends + + backend = registered_backends().get(input.backend_ref) + if backend is None: + raise ApplicationError( + f"Backend {input.backend_ref!r} is not registered on this worker.", + type="DeepAgentsUnknownBackend", + non_retryable=True, + ) + method = getattr(backend, input.op, None) + if method is None: + raise ApplicationError( + f"Backend {input.backend_ref!r} has no operation {input.op!r}.", + type="DeepAgentsUnknownBackendOp", + non_retryable=True, + ) + result = method(*input.args, **input.kwargs) + if asyncio.iscoroutine(result): + result = await result + # Protocol results are plain dataclasses whose attributes the + # middleware reads in-workflow — tag them so the stub can rebuild + # the real type instead of receiving a decayed dict. + return BackendOpOutput(result=_serde.dump_backend_result(result)) diff --git a/temporalio/contrib/deepagents/_model.py b/temporalio/contrib/deepagents/_model.py new file mode 100644 index 000000000..fc68514c5 --- /dev/null +++ b/temporalio/contrib/deepagents/_model.py @@ -0,0 +1,368 @@ +"""The model seam: a chat model whose every call becomes a Temporal activity. + +:class:`TemporalModel` is a real ``BaseChatModel``. Placed anywhere Deep Agents +expects a model, it routes each generation through the ``deepagents.invoke_model`` +activity instead of calling the provider inline. Because Deep Agents' sub-agents +inherit the parent's ``model`` instance by default, substituting this one object +makes every model call in the whole agent tree durable — no middleware injection +that a sub-agent could silently miss. + +Two ways to get one: + +* explicit — the user writes ``TemporalModel("anthropic:claude-...")`` and hands + it to ``create_deep_agent(model=...)``; this is the public type users assert + against; +* implicit — while running inside a workflow, the plugin patches + ``create_deep_agent`` so a bare ``model="anthropic:..."`` string is wrapped + automatically (see :func:`install_model_patch`). + +Worker-wide dispatch defaults (activity options, streaming topic) live in +:class:`temporalio.contrib.deepagents._serde.Settings`, a module global set by +the plugin. They live in ``_serde`` (not here) so the plugin can configure them +without importing this module — which would drag LangChain into plugin +construction. This module is under ``temporalio``, which the workflow sandbox +passes through, so the object the workflow reads is the same one the plugin set. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, AsyncIterator, Iterator, Mapping, Sequence + +from temporalio import workflow +from temporalio.contrib.deepagents import _activity, _serde + +with workflow.unsafe.imports_passed_through(): + from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, + ) + from langchain_core.language_models import BaseChatModel + from langchain_core.messages import AIMessageChunk, BaseMessage + from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult + + +def _as_message_chunk(message: Any) -> Any: + """Re-shape a finished ``AIMessage`` as the ``AIMessageChunk`` a stream yields.""" + return AIMessageChunk( + content=getattr(message, "content", ""), + additional_kwargs=getattr(message, "additional_kwargs", {}) or {}, + response_metadata=getattr(message, "response_metadata", {}) or {}, + id=getattr(message, "id", None), + tool_calls=getattr(message, "tool_calls", []) or [], + usage_metadata=getattr(message, "usage_metadata", None), + ) + + +# --------------------------------------------------------------------------- +# Activity-option resolution (worker-wide defaults live in _serde.Settings) +# --------------------------------------------------------------------------- + + +_DEFAULT_MODEL_TIMEOUT = timedelta(minutes=5) + + +def _resolve_activity_options( + model_name: str, instance_options: Mapping[str, Any] | None +) -> dict[str, Any]: + """Merge worker defaults with a per-instance override into execute_activity kwargs.""" + opts: dict[str, Any] = {} + default = _serde.get_settings().model_activity_options + if isinstance(default, Mapping) and not _looks_like_activity_config(default): + # Mapping[model_name, ActivityConfig]: pick this model's entry. + chosen = default.get(model_name) + if isinstance(chosen, Mapping): + opts.update(chosen) + elif isinstance(default, Mapping): + opts.update(default) + if instance_options: + opts.update(instance_options) + opts.setdefault("start_to_close_timeout", _DEFAULT_MODEL_TIMEOUT) + return opts + + +def _looks_like_activity_config(m: Mapping[str, Any]) -> bool: + """A single ``ActivityConfig`` has known option keys; a per-model map does not.""" + known = { + "task_queue", + "schedule_to_close_timeout", + "schedule_to_start_timeout", + "start_to_close_timeout", + "heartbeat_timeout", + "retry_policy", + "cancellation_type", + "activity_id", + "versioning_intent", + "summary", + "priority", + } + return bool(m) and all(k in known for k in m) + + +# --------------------------------------------------------------------------- +# The model +# --------------------------------------------------------------------------- + + +class TemporalModel(BaseChatModel): + """A ``BaseChatModel`` that runs each generation as a Temporal activity. + + Args: + model: The provider model name resolved worker-side by the plugin's + ``model_provider`` (e.g. ``"anthropic:claude-sonnet-4-5"``). Only the + name crosses the workflow boundary; credentials stay on the worker. + activity_options: Optional per-model ``execute_activity`` overrides + (timeouts, retry policy). Falls back to the plugin's + ``model_activity_options``. + """ + + model: str + activity_options: dict[str, Any] | None = None + + # ``protected_namespaces=()`` silences pydantic's warning about the ``model`` + # field colliding with its ``model_`` namespace. + model_config = {"arbitrary_types_allowed": True, "protected_namespaces": ()} + + @property + def _llm_type(self) -> str: + return "temporal-deepagents" + + def bind_tools( + self, + tools: Sequence[Any], + *, + tool_choice: Any | None = None, + **kwargs: Any, + ) -> Any: + """Bind tools to the model the way LangChain's ``create_agent`` expects. + + ``BaseChatModel.bind_tools`` is abstract (raises ``NotImplementedError``), + but the agent factory calls ``model.bind_tools(tools, ...)`` on every model + node — so a durable model must implement it or the whole loop dies. The + tools are converted to their JSON schema *now* (at bind time) so the bound + object is serialization-safe, and carried as the ``tools`` kwarg that + :meth:`_build_input` already reads and forwards to the activity, where the + real provider model is what actually binds them. + """ + schemas = [ + t if isinstance(t, dict) else _serde.tool_to_schema(t) for t in tools + ] + if tool_choice is not None: + kwargs["tool_choice"] = tool_choice + return self.bind(tools=schemas, **kwargs) + + def _summary(self) -> str: + return f"invoke_model[{self.model}]" + + def _build_input( + self, + messages: Sequence[BaseMessage], + streaming_topic: str | None, + **kwargs: Any, + ) -> _activity.ModelActivityInput: + tools = kwargs.get("tools") or [] + tool_schemas = [ + t if isinstance(t, dict) else _serde.tool_to_schema(t) for t in tools + ] + bind_kwargs = { + k: v + for k, v in kwargs.items() + if k not in ("tools", "config", "run_manager", "stop", "callbacks") + and _serde._is_jsonish(v) + } + if kwargs.get("stop"): + bind_kwargs["stop"] = kwargs["stop"] + return _activity.ModelActivityInput( + model_name=self.model, + messages=_serde.dump_messages(messages), + tool_schemas=tool_schemas, + bind_kwargs=bind_kwargs, + config=_serde.strip_runnable_config(kwargs.get("config") or {}), + streaming_topic=streaming_topic, + ) + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + raise NotImplementedError( + "TemporalModel is async-only inside a Temporal workflow. Drive the " + "agent with `await agent.ainvoke(...)` / `.astream(...)`, which uses " + "the async model path." + ) + + async def _agenerate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> ChatResult: + from temporalio.contrib.deepagents.workflow import call_model + + activity_input = self._build_input(messages, None, stop=stop, **kwargs) + opts = _resolve_activity_options(self.model, self.activity_options) + output = await call_model( + _activity.INVOKE_MODEL, + activity_input, + summary=self._summary(), + **opts, + ) + message = _serde.load_object(output.message) + return ChatResult(generations=[ChatGeneration(message=message)]) + + async def _astream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> AsyncIterator[ChatGenerationChunk]: + from temporalio.contrib.deepagents.workflow import call_model + + topic = _serde.get_settings().streaming_topic + opts = _resolve_activity_options(self.model, self.activity_options) + if topic: + activity_input = self._build_input(messages, topic, stop=stop, **kwargs) + output = await call_model( + _activity.INVOKE_MODEL_STREAMING, + activity_input, + summary=f"invoke_model_streaming[{self.model}]", + **opts, + ) + else: + # No topic configured: fall back to a single response-level chunk. + activity_input = self._build_input(messages, None, stop=stop, **kwargs) + output = await call_model( + _activity.INVOKE_MODEL, + activity_input, + summary=self._summary(), + **opts, + ) + message = _serde.load_object(output.message) + yield ChatGenerationChunk(message=_as_message_chunk(message)) + + def _stream( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, + **kwargs: Any, + ) -> Iterator[ChatGenerationChunk]: + raise NotImplementedError( + "TemporalModel streaming is async-only; use `agent.astream(...)`." + ) + + +# --------------------------------------------------------------------------- +# create_deep_agent model patch (implicit wrapping) +# --------------------------------------------------------------------------- +# +# The durability seam is ``deepagents._models.resolve_model``, which +# ``create_deep_agent`` calls to turn a ``model=`` string (or instance) into a +# ``BaseChatModel`` — for both the top-level agent and every ``SubAgent`` +# (``graph.py`` lines 592 and 634). We patch it on the ``deepagents.graph`` +# module, where ``create_deep_agent``'s body resolves the ``resolve_model`` name +# at call time. +# +# Patching *this* seam (not ``deepagents.create_deep_agent``) is what makes the +# rewrite survive the user's import style. A user who writes the idiomatic +# ``from deepagents import create_deep_agent`` binds the *original* function +# object into their module; rebinding the ``deepagents.create_deep_agent`` +# attribute would never be seen by that already-bound reference, so string +# models would reach the real provider inside the workflow (a hang / non- +# determinism). ``create_deep_agent``'s body, by contrast, always looks up +# ``resolve_model`` in the ``deepagents.graph`` globals afresh on each call, so +# rebinding it there is observed no matter how the caller imported the factory. +# It also preserves ``_model_spec`` (the original string), which the factory +# reads *before* calling ``resolve_model`` for harness-profile lookup. +# +# ``create_deep_agent`` is still wrapped separately, best-effort, purely to fire +# the construction-time warnings that need the ``tools`` / ``checkpointer`` +# kwargs (those warnings are advisory and carry no durability weight). + +_original_create_deep_agent: Any = None +_original_resolve_model: Any = None + + +def _wrap_model_arg(model: Any) -> Any: + """Resolve a ``create_deep_agent`` model argument to a durable model. + + A name string becomes a :class:`TemporalModel`. A :class:`TemporalModel` + passes through. Any other live ``BaseChatModel`` instance is rejected at the + workflow boundary: it has no name the activity could rebuild from, so it would + run its provider call *inside the workflow* — nondeterministic and unsafe. + """ + from temporalio.contrib.deepagents.workflow import DeepAgentsWorkflowError + + if isinstance(model, str): + return TemporalModel(model=model) + if isinstance(model, TemporalModel): + return model + if isinstance(model, BaseChatModel): + raise DeepAgentsWorkflowError( + f"create_deep_agent received a live {type(model).__name__} model " + f"instance, which would run inside the workflow (nondeterministic). " + f'Pass model="provider:name" (auto-routed through an activity) or ' + f"wrap it as TemporalModel(...) instead." + ) + return model + + +def install_model_patch() -> None: + """Route Deep Agents' model resolution through :class:`TemporalModel`. + + Patches ``deepagents.graph.resolve_model`` (the seam ``create_deep_agent`` + uses for the main agent *and* every sub-agent) so a bare ``model="..."`` + string becomes a durable :class:`TemporalModel`, and additionally wraps + ``deepagents.create_deep_agent`` to fire the advisory tool / checkpointer + warnings. Both only act when called inside a workflow, so importing + deepagents on a plain client / activity worker is unaffected. Idempotent. + """ + global _original_create_deep_agent, _original_resolve_model + import deepagents + import deepagents.graph as _graph + + if _original_resolve_model is None: + _original_resolve_model = _graph.resolve_model # pyright: ignore[reportPrivateImportUsage] + + def patched_resolve_model(model: Any) -> Any: + if workflow.in_workflow(): + return _wrap_model_arg(model) + return _original_resolve_model(model) + + _graph.resolve_model = patched_resolve_model # pyright: ignore[reportPrivateImportUsage] + + if _original_create_deep_agent is None: + _original_create_deep_agent = deepagents.create_deep_agent + + def patched(*args: Any, **kwargs: Any) -> Any: + if workflow.in_workflow(): + from temporalio.contrib.deepagents._tools import warn_unwrapped_tools + from temporalio.contrib.deepagents.workflow import ( + warn_durable_checkpointer, + ) + + warn_unwrapped_tools(kwargs.get("tools")) + warn_durable_checkpointer(kwargs.get("checkpointer")) + return _original_create_deep_agent(*args, **kwargs) + + deepagents.create_deep_agent = patched + + +def uninstall_model_patch() -> None: + """Restore the original ``resolve_model`` / ``create_deep_agent``.""" + global _original_create_deep_agent, _original_resolve_model + if _original_resolve_model is not None: + import deepagents.graph as _graph + + _graph.resolve_model = _original_resolve_model # pyright: ignore[reportPrivateImportUsage] + _original_resolve_model = None + if _original_create_deep_agent is not None: + import deepagents + + deepagents.create_deep_agent = _original_create_deep_agent + _original_create_deep_agent = None diff --git a/temporalio/contrib/deepagents/_plugin.py b/temporalio/contrib/deepagents/_plugin.py new file mode 100644 index 000000000..85e66f9b7 --- /dev/null +++ b/temporalio/contrib/deepagents/_plugin.py @@ -0,0 +1,284 @@ +"""The plugin object users add to ``plugins=[...]``. + +:class:`DeepAgentsPlugin` wires everything together so that existing +``deepagents`` code — ``create_deep_agent(...).ainvoke(...)`` — runs durably +inside a ``@workflow.defn`` with no other changes: + +* registers the four activities that carry the nondeterministic work; +* installs the LangChain-aware data converter (composing, never clobbering, a + user converter); +* passes the LangChain / LangGraph / deepagents import tree through the workflow + sandbox; +* wraps the worker run in a ``run_context`` that patches Deep Agents' model + resolution seam (``deepagents.graph.resolve_model``) to auto-route bare + ``model=`` strings through activities — regardless of how the user imported + ``create_deep_agent``; +* registers :class:`DeepAgentsWorkflowError` as a workflow-failure type; +* pushes the model / tool dispatch defaults down to the seams that read them. + +The plugin auto-propagates from a ``Client`` to any ``Worker`` built from it, so +add it on exactly one side. ``configure_worker`` additionally de-duplicates +activities by name, so a user who mistakenly adds it on both sides gets a clean +no-op instead of a "More than one activity named ..." crash. +""" + +from __future__ import annotations + +import sys +import warnings +from contextlib import asynccontextmanager +from dataclasses import replace +from datetime import timedelta +from typing import Any, AsyncIterator, Callable, Mapping, Sequence + +from temporalio import activity as activity_mod +from temporalio.contrib.deepagents import _serde, _tools +from temporalio.contrib.deepagents._activity import DeepAgentActivities +from temporalio.contrib.deepagents.workflow import DeepAgentsWorkflowError +from temporalio.plugin import SimplePlugin +from temporalio.worker import WorkflowRunner +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner + + +class DeepAgentsPlugin(SimplePlugin): + """Temporal plugin that makes LangChain Deep Agents durable. + + Args: + model_provider: Builds a real chat model from a name string, worker-side. + This is where API keys live; only the model name ever crosses the + workflow boundary. Defaults to LangChain's ``init_chat_model`` with + LLM-SDK retries disabled (Temporal owns retries). + model_activity_options: ``ActivityConfig`` (or ``Mapping[model_name, + ActivityConfig]``) for the model activities — timeouts, retry policy. + tool_activity_options: ``ActivityConfig`` (or ``Mapping[tool_name, + ActivityConfig]``) default for tools wrapped with ``tool_as_activity``. + streaming_topic: When set, model calls stream through the streaming + activity and publish chunk batches to this workflow-streams topic. + streaming_batch_interval: How long the streaming activity coalesces + chunks before publishing a batch. + passthrough_modules: Extra sandbox-passthrough modules, merged with the + plugin's LangChain/deepagents defaults. + data_converter: Override the default LangChain-aware converter. ``None`` + installs the default; the SDK default is upgraded in place; any other + converter raises (fold ``DeepAgentsPayloadConverter`` into your own). + """ + + def __init__( + self, + *, + model_provider: Callable[[str], Any] | None = None, + model_activity_options: Any = None, + tool_activity_options: Any = None, + streaming_topic: str | None = None, + streaming_batch_interval: timedelta = timedelta(milliseconds=100), + passthrough_modules: Sequence[str] | None = None, + data_converter: Any = None, + ) -> None: + """Configure the plugin; see the class docstring for parameters.""" + if sys.version_info < (3, 11): + warnings.warn( + "DeepAgentsPlugin requires Python >= 3.11 (deepagents pins " + ">=3.11); the Deep Agents control loop relies on contextvars " + "propagation through asyncio that older versions lack.", + stacklevel=2, + ) + + self._passthrough_modules = passthrough_modules + # Held on the instance so the wiring is statically traceable: each is + # passed straight into the call that consumes it (below), not stashed as + # dead config. + self._tool_activity_options = tool_activity_options + self._data_converter = data_converter + + # Push dispatch defaults down to the model and tool seams that read them. + # These live in ``_serde`` / ``_tools`` (langchain-free modules) so + # constructing the plugin never imports LangChain. ``tool_activity_options`` + # is stored under ``_tools._tool_defaults`` and read back on the tool + # dispatch path by ``_tools._resolve_tool_options(...)`` — which + # ``tool_as_activity`` calls to compute each tool activity's timeout/retry + # when the caller does not override ``activity_options``. + _serde.set_settings( + model_activity_options=model_activity_options, + streaming_topic=streaming_topic, + ) + # wired-via-composition: consumed on the tool dispatch path in _tools.py + # (_resolve_tool_options), renamed to ``options`` at the callsite. + _tools.set_tool_defaults(self._tool_activity_options) + + # The activities that carry the nondeterministic work. model_provider and + # the batch interval are captured here so API keys never enter an input. + self._activities = DeepAgentActivities( + model_provider=model_provider, + streaming_batch_interval=streaming_batch_interval, + ) + activities = [ + self._activities.invoke_model, + self._activities.invoke_model_streaming, + self._activities.invoke_tool, + self._activities.backend_op, + ] + + super().__init__( + "langchain.DeepAgentsPlugin", + activities=activities, + # wired-via-composition: ``data_converter`` flows into SimplePlugin's + # own ``data_converter`` kwarg (renamed to ``user_converter`` inside + # build_data_converter), which installs it on the client/worker. + data_converter=_serde.build_data_converter(self._data_converter), + workflow_runner=self._make_workflow_runner(), + workflow_failure_exception_types=[DeepAgentsWorkflowError], + run_context=self._run_context, + ) + + # -- sandbox passthrough ------------------------------------------------- + + def _make_workflow_runner( + self, + ) -> Callable[[WorkflowRunner | None], WorkflowRunner]: + modules = _serde.resolve_passthrough_modules(self._passthrough_modules) + + def workflow_runner(runner: WorkflowRunner | None) -> WorkflowRunner: + if runner is None: + raise ValueError("No WorkflowRunner provided to DeepAgentsPlugin.") + if isinstance(runner, SandboxedWorkflowRunner): + return replace( + runner, + restrictions=runner.restrictions.with_passthrough_modules(*modules), + ) + return runner + + return workflow_runner + + # -- run context --------------------------------------------------------- + + @asynccontextmanager + async def _run_context(self) -> AsyncIterator[None]: + """Patch Deep Agents' model resolution seam for the worker's lifetime. + + The patch (on ``deepagents.graph.resolve_model``, the seam + ``create_deep_agent`` uses for the agent and every sub-agent) only + rewrites ``model=`` strings when running inside a workflow, so it is inert + on plain clients / activity workers. Determinism itself + rides on the same mechanism ``contrib.langgraph`` uses — the loop is + in-workflow and every nondeterministic call is an activity — so no + speculative time/uuid shims are installed here. + + One shim *is* required: LangChain's ``create_agent`` factory wraps every + model node with LangSmith's ``@traceable``, whose async path hops onto a + thread via ``asyncio.run_in_executor`` — which the deterministic workflow + event loop does not implement (it raises ``NotImplementedError``). Tracing + is an observability concern that must not run in-workflow, so we install + LangSmith's own Temporal escape hatch (``set_runtime_overrides``) to run + that setup inline when ``in_workflow()``, and defer to the default thread + hop everywhere else (activities / clients, where tracing is fine). + """ + # Imported lazily (not at module top) so plugin construction stays + # langchain-free; the patch is only needed once the worker is running. + # The import itself is inside the guard: on a worker without LangChain + # installed (e.g. one that only runs the plugin's determinism / failure + # paths) importing ``_model`` raises ``ModuleNotFoundError``, and the + # worker must still start — it simply runs without the auto-wrap patch. + patched = False + try: + from temporalio.contrib.deepagents import _model + + _model.install_model_patch() + patched = True + except Exception: # LangChain / deepagents not importable on this worker + warnings.warn( + "DeepAgentsPlugin could not patch create_deep_agent; use explicit " + "TemporalModel(...) instances to route model calls through " + "activities.", + stacklevel=2, + ) + langsmith_patched = _install_langsmith_temporal_override() + try: + yield + finally: + if patched: + # Import is cached: patched=True implies the import above succeeded. + from temporalio.contrib.deepagents import _model + + _model.uninstall_model_patch() + if langsmith_patched: + _uninstall_langsmith_temporal_override() + + # -- worker config ------------------------------------------------------- + + def configure_worker(self, config: Any) -> Any: + """Deduplicate activity registrations after the base configuration.""" + config = super().configure_worker(config) + activities = config.get("activities") + if activities: + config["activities"] = _dedupe_activities(activities) + return config + + +async def _temporal_aio_to_thread( + default_aio_to_thread: Callable[..., Any], + ctx: Any, + func: Callable[..., Any], + /, + *args: Any, + **kwargs: Any, +) -> Any: + """Run LangSmith's ``aio_to_thread`` seam safely inside a workflow. + + Outside a workflow (activities, clients) we defer to LangSmith's default + thread hop. Inside a workflow the deterministic event loop cannot spawn a + thread, so we run the setup inline in the requested context. ``func`` here is + LangSmith's own run-tree bookkeeping — cheap and side-effect-free with respect + to workflow determinism. + """ + from temporalio import workflow + + if not workflow.in_workflow(): + return await default_aio_to_thread(ctx, func, *args, **kwargs) + with workflow.unsafe.sandbox_unrestricted(): + return ctx.run(func, *args, **kwargs) + + +def _install_langsmith_temporal_override() -> bool: + """Route LangSmith's thread hop inline while in a workflow. Returns success. + + ``set_runtime_overrides`` mutates a module global in the sandbox-passthrough + ``langsmith`` package, so the in-workflow tracer sees it too. No-op (and + harmless) when LangSmith is not installed. + """ + try: + import langsmith + + langsmith.set_runtime_overrides(aio_to_thread=_temporal_aio_to_thread) + return True + except Exception: + return False + + +def _uninstall_langsmith_temporal_override() -> None: + """Restore LangSmith's default runtime behavior.""" + try: + import langsmith + + langsmith.set_runtime_overrides() + except Exception: + pass + + +def _dedupe_activities(activities: Sequence[Any]) -> list[Any]: + """Drop duplicate activity registrations by defn name, keeping the first. + + Guards the "plugin added on both Client and Worker" trap, where the plugin's + activities would otherwise be appended twice and the worker would reject the + duplicate names. + """ + seen: set[str] = set() + out: list[Any] = [] + for act in activities: + defn = activity_mod._Definition.from_callable(act) + name = defn.name if defn is not None else getattr(act, "__name__", repr(act)) + key = name or repr(act) + if key in seen: + continue + seen.add(key) + out.append(act) + return out diff --git a/temporalio/contrib/deepagents/_serde.py b/temporalio/contrib/deepagents/_serde.py new file mode 100644 index 000000000..61af4c43e --- /dev/null +++ b/temporalio/contrib/deepagents/_serde.py @@ -0,0 +1,410 @@ +"""Serialization helpers, a result cache, and worker-runtime configuration. + +The Deep Agents control loop runs *inside* the Temporal workflow, so the values +that actually cross the workflow⇄activity boundary are a small set of LangChain +types: chat messages, tool-call descriptors, and the ``RunnableConfig`` metadata +attached to each model / tool call. LangChain messages are polymorphic +``Serializable`` models (an ``AIMessage`` must not be rehydrated as a +``ToolMessage``) and ``RunnableConfig`` carries live callback / checkpointer +references, so neither survives a naive round-trip. This module owns: + +* message (de)serialization via ``langchain_core.load.dumpd`` / ``load`` — the + round-trip that preserves message subtype and tool-call structure; +* the strip → ship → rebuild dance for ``RunnableConfig``; +* tool → JSON-schema advertisement (full name + description + argument schema, + never ``{name, description}`` alone — without the argument schema the model + picks the right tool but hallucinates its arguments); +* the Pydantic data converter (``exclude_unset=True``) the plugin installs on + the client and replayer, so message payloads stay small and round-trip + cleanly; +* a workflow-scoped result cache so model / tool results computed before a + ``continue_as_new`` are reused rather than recomputed after it; +* the sandbox passthrough module list covering LangChain's transitive + eager-import tree. + +LangChain imports are deferred into the functions that need them, so importing +this module — and constructing the plugin — does not require LangChain to be +installed on the machine assembling the worker. +""" + +from __future__ import annotations + +import contextvars +import dataclasses +import hashlib +import json +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from langchain_core.runnables import RunnableConfig + +from temporalio.contrib.pydantic import PydanticPayloadConverter, ToJsonOptions +from temporalio.converter import DataConverter + +# --------------------------------------------------------------------------- +# Worker-wide dispatch settings +# --------------------------------------------------------------------------- +# +# These are fixed for the worker's lifetime (not per-workflow state), so a module +# global is correct. This module lives under ``temporalio``, which the workflow +# sandbox passes through, so the object the in-workflow model stub reads is the +# same one the plugin configured. They live here (not in ``_model``) so that +# ``DeepAgentsPlugin`` can push them down without importing ``_model`` — which +# would drag in LangChain at plugin-construction time. + + +@dataclasses.dataclass +class Settings: + """Dispatch defaults shared by every ``TemporalModel`` on the worker.""" + + model_activity_options: Any = None + """``ActivityConfig`` or ``Mapping[model_name, ActivityConfig]``.""" + streaming_topic: str | None = None + + +_settings = Settings() + + +def set_settings( + *, + model_activity_options: Any = None, + streaming_topic: str | None = None, +) -> None: + """Install the worker-wide model dispatch defaults (called by the plugin).""" + _settings.model_activity_options = model_activity_options + _settings.streaming_topic = streaming_topic + + +def get_settings() -> Settings: + """Return the active dispatch settings.""" + return _settings + + +# --------------------------------------------------------------------------- +# Data converter +# --------------------------------------------------------------------------- + + +class DeepAgentsPayloadConverter(PydanticPayloadConverter): + """Pydantic payload converter pinned to ``exclude_unset=True``. + + LangChain request/response types (and ``DeepAgentState``) are deeply nested + with many ``Optional[...] = None`` fields. Shipping every unset default + inflates payloads several-fold and some peers reject the explicit nulls on + round-trip, so we exclude unset fields by convention. + """ + + def __init__(self) -> None: + """Construct the converter with ``exclude_unset`` serialization.""" + super().__init__(ToJsonOptions(exclude_unset=True)) + + +data_converter = DataConverter(payload_converter_class=DeepAgentsPayloadConverter) +"""The plugin's default data converter (LangChain messages are shipped as their +``dumpd`` JSON form, so the Pydantic converter only ever sees plain containers).""" + + +def build_data_converter( + user_converter: DataConverter | None, +) -> DataConverter: + """Compose the plugin's converter with whatever the caller already set. + + * ``None`` — install the plugin default. + * the SDK default converter — swap in the LangChain-aware Pydantic + converter via :func:`dataclasses.replace`. + * a custom converter — refuse rather than silently clobber it; the caller + must fold :class:`DeepAgentsPayloadConverter` into their own converter. + """ + if user_converter is None: + return data_converter + if user_converter is DataConverter.default: + return dataclasses.replace( + user_converter, payload_converter_class=DeepAgentsPayloadConverter + ) + raise ValueError( + "DeepAgentsPlugin cannot compose with a custom data_converter " + "automatically. Set payload_converter_class=DeepAgentsPayloadConverter " + "on your own DataConverter (so LangChain messages serialize with " + "exclude_unset=True), or omit data_converter to use the plugin default." + ) + + +# --------------------------------------------------------------------------- +# LangChain object (de)serialization +# --------------------------------------------------------------------------- + + +def dump_object(obj: Any) -> Any: + """Serialize a single LangChain ``Serializable`` (message, tool call, …).""" + from langchain_core.load import dumpd + + return dumpd(obj) + + +def load_object(data: Any) -> Any: + """Rehydrate a value produced by :func:`dump_object`, preserving subtype.""" + from langchain_core.load import load + + return load(data) + + +# --------------------------------------------------------------------------- +# Backend protocol result (de)serialization +# --------------------------------------------------------------------------- + +_BACKEND_DATACLASS_KEY = "__deepagents_dataclass__" + + +def dump_backend_result(value: Any) -> Any: + """Encode a backend op's return value for the activity boundary. + + Backend protocol results (``WriteResult`` / ``ReadResult`` / ``GrepResult`` + and their nested ``FileInfo`` / ``GrepMatch`` items, …) are plain + dataclasses — not LangChain ``Serializable``s — and the filesystem + middleware reads their ATTRIBUTES in-workflow, so a plain JSON round-trip + (which decays them to dicts) breaks the seam at the first real backend op. + Tag deepagents dataclasses with their import path so + :func:`load_backend_result` rebuilds the real type; anything else (str, + dict, a custom backend's own types) passes through with today's + plain-JSON behavior. + """ + import dataclasses + + if dataclasses.is_dataclass(value) and not isinstance(value, type): + cls = type(value) + dumped_fields = { + f.name: dump_backend_result(getattr(value, f.name)) + for f in dataclasses.fields(value) + } + if cls.__module__.split(".", 1)[0] == "deepagents": + return { + _BACKEND_DATACLASS_KEY: f"{cls.__module__}:{cls.__qualname__}", + "fields": dumped_fields, + } + return dumped_fields + if isinstance(value, (list, tuple)): + return [dump_backend_result(v) for v in value] + if isinstance(value, dict): + return {k: dump_backend_result(v) for k, v in value.items()} + return value + + +def load_backend_result(value: Any) -> Any: + """Rebuild a value produced by :func:`dump_backend_result`. + + Only ``deepagents.*`` dataclasses are reconstructed (the tag is written + exclusively for them); anything else would mean a forged payload, so + refuse rather than import arbitrary types. Reconstruction suppresses + ``DeprecationWarning``: this is transport, not user code — the backend + already constructed the object once on the activity side, and required + deprecated fields (e.g. ``WriteResult.files_update``) would otherwise + warn on every op. Field values equal to a declared default are omitted + from the constructor call. + """ + import dataclasses + import importlib + import warnings + + if isinstance(value, dict) and _BACKEND_DATACLASS_KEY in value: + path = value[_BACKEND_DATACLASS_KEY] + module_name, _, qualname = path.partition(":") + if module_name.split(".", 1)[0] != "deepagents": + raise ValueError(f"refusing to rebuild non-deepagents type {path!r}") + obj: Any = importlib.import_module(module_name) + for part in qualname.split("."): + obj = getattr(obj, part) + if not (isinstance(obj, type) and dataclasses.is_dataclass(obj)): + raise ValueError(f"{path!r} is not a dataclass") + loaded = {k: load_backend_result(v) for k, v in value["fields"].items()} + kwargs = {} + for f in dataclasses.fields(obj): + if f.name not in loaded: + continue + if f.default is not dataclasses.MISSING and loaded[f.name] == f.default: + continue + kwargs[f.name] = loaded[f.name] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return obj(**kwargs) + if isinstance(value, list): + return [load_backend_result(v) for v in value] + if isinstance(value, dict): + return {k: load_backend_result(v) for k, v in value.items()} + return value + + +def dump_messages(messages: Any) -> list[Any]: + """Serialize a sequence of LangChain messages to their ``dumpd`` form.""" + from langchain_core.load import dumpd + + return [dumpd(m) for m in messages] + + +def load_messages(dumped: list[Any]) -> list[Any]: + """Rehydrate messages serialized by :func:`dump_messages`.""" + from langchain_core.load import load + + return [load(d) for d in dumped] + + +def tool_to_schema(tool: Any) -> dict[str, Any]: + """Advertise a tool to the model as a full OpenAI tool schema. + + Carries name + description + argument JSON schema so the model can build + valid arguments, not just select the tool by name. + """ + from langchain_core.utils.function_calling import convert_to_openai_tool + + return convert_to_openai_tool(tool) + + +# --------------------------------------------------------------------------- +# RunnableConfig strip / rebuild +# --------------------------------------------------------------------------- + + +def _is_jsonish(value: Any) -> bool: + if value is None or isinstance(value, (str, int, float, bool)): + return True + if isinstance(value, (list, tuple)): + return all(_is_jsonish(v) for v in value) + if isinstance(value, dict): + return all(isinstance(k, str) and _is_jsonish(v) for k, v in value.items()) + return False + + +def strip_runnable_config(config: Any) -> dict[str, Any]: + """Reduce a live ``RunnableConfig`` to its JSON-safe subset for shipping. + + Keeps ``tags``, ``run_name``, ``run_id``, ``recursion_limit``, JSON-safe + ``metadata`` and the JSON-safe (non-dunder) ``configurable`` keys. Drops + callbacks, checkpointer / store / cache handles and every other live + reference — those are reconstructed activity-side. + """ + if not config: + return {} + out: dict[str, Any] = {} + if config.get("tags"): + out["tags"] = list(config["tags"]) + for key in ("run_id", "run_name"): + if config.get(key) is not None: + out[key] = str(config[key]) + if config.get("recursion_limit") is not None: + out["recursion_limit"] = config["recursion_limit"] + metadata = config.get("metadata") + if isinstance(metadata, dict): + out["metadata"] = {k: v for k, v in metadata.items() if _is_jsonish(v)} + configurable = config.get("configurable") + if isinstance(configurable, dict): + kept = { + k: v + for k, v in configurable.items() + if not k.startswith("__") and _is_jsonish(v) + } + if kept: + out["configurable"] = kept + return out + + +def rebuild_runnable_config(data: dict[str, Any]) -> "RunnableConfig": + """Reconstruct a minimal ``RunnableConfig`` from :func:`strip_runnable_config`.""" + config: dict[str, Any] = {"metadata": dict(data.get("metadata", {}))} + if data.get("tags"): + config["tags"] = list(data["tags"]) + for key in ("run_id", "run_name"): + if data.get(key) is not None: + config[key] = data[key] + if data.get("recursion_limit") is not None: + config["recursion_limit"] = data["recursion_limit"] + if data.get("configurable"): + config["configurable"] = dict(data["configurable"]) + # Double cast: a TypedDict and dict[str, Any] "insufficiently overlap" + # for basedpyright's reportInvalidCast; object is the sanctioned bridge. + return cast("RunnableConfig", cast(object, config)) + + +# --------------------------------------------------------------------------- +# Result cache (continue-as-new dedup) +# --------------------------------------------------------------------------- + +# Per-workflow state: set at the top of the workflow run, read by the model and +# tool dispatch paths, snapshotted for continue-as-new. A ContextVar (not a +# module-global keyed by run id) so update / signal handler tasks spawned by the +# workflow inherit the same cache automatically. +_result_cache: contextvars.ContextVar[dict[str, Any] | None] = contextvars.ContextVar( + "_deepagents_result_cache", default=None +) + + +def set_result_cache(cache: dict[str, Any] | None) -> None: + """Seed the workflow-scoped result cache (e.g. carried across CAN).""" + _result_cache.set(dict(cache) if cache else {}) + + +def result_cache_snapshot() -> dict[str, Any] | None: + """Return a serializable copy of the cache, or ``None`` when empty.""" + cache = _result_cache.get() + return dict(cache) if cache else None + + +def cache_key(kind: str, call_id: str, args: Any) -> str: + """Stable key over ``(kind, call_id, args)`` for cache lookups.""" + blob = json.dumps([kind, call_id, args], default=str, sort_keys=True) + return hashlib.sha256(blob.encode("utf-8")).hexdigest() + + +def cache_lookup(key: str) -> tuple[bool, Any]: + """Return ``(hit, value)`` for ``key`` in the active cache.""" + cache = _result_cache.get() + if cache is not None and key in cache: + return True, cache[key] + return False, None + + +def cache_put(key: str, value: Any) -> None: + """Record ``value`` under ``key`` when a cache is active for this run.""" + cache = _result_cache.get() + if cache is not None: + cache[key] = value + + +# --------------------------------------------------------------------------- +# Sandbox passthrough +# --------------------------------------------------------------------------- + +# The workflow sandbox re-imports modules per run; LangChain / LangGraph / +# deepagents build large class hierarchies with eager import side effects, and +# LangSmith pulls in numpy. Passing them through means "import once in the host +# and share", which is both faster and required for identity checks +# (isinstance across the sandbox boundary) to hold. +_DEFAULT_PASSTHROUGH: tuple[str, ...] = ( + "langchain", + "langchain_core", + "langchain_anthropic", + "langgraph", + "deepagents", + "langsmith", + "numpy", + "pydantic", + "pydantic_core", + "anthropic", + "tiktoken", + "jsonpatch", + "jsonpointer", + "tenacity", + "orjson", + "httpx", + "httpcore", +) + + +def default_passthrough_modules() -> tuple[str, ...]: + """The LangChain / deepagents transitive import tree passed through the sandbox.""" + return _DEFAULT_PASSTHROUGH + + +def resolve_passthrough_modules(user: Any) -> tuple[str, ...]: + """Merge caller-supplied passthrough modules with the plugin defaults.""" + merged = [*_DEFAULT_PASSTHROUGH, *(user or ())] + # dict.fromkeys preserves order while de-duplicating. + return tuple(dict.fromkeys(merged)) diff --git a/temporalio/contrib/deepagents/_tools.py b/temporalio/contrib/deepagents/_tools.py new file mode 100644 index 000000000..00815a7be --- /dev/null +++ b/temporalio/contrib/deepagents/_tools.py @@ -0,0 +1,403 @@ +"""The tool + backend seams: the explicit per-unit Workflow-vs-Activity choice. + +Deep Agents holds its tools and filesystem/shell backends in-workflow. A tool or +backend op that only reads and writes ``DeepAgentState`` is pure and belongs in +the workflow (deterministic, replay-safe). One that does real I/O — a web +search, a shell command, a disk write — must not run there. This module gives +the user three explicit ways to move that work to an activity: + +* :func:`activity_as_tool` — expose an existing ``@activity.defn`` as a Deep + Agents tool (Temporal adopters already have activities; don't make them + re-declare); +* :func:`tool_as_activity` — wrap a LangChain ``BaseTool`` / callable so its + execution runs as an activity; +* :class:`TemporalBackend` — wrap a real-I/O backend so each file/exec op runs + as an activity. + +The choice is always explicit: an unwrapped non-builtin tool runs in-workflow, +and the plugin warns at construction so that is a conscious decision, never a +silent one. + +Registries here live in a ``temporalio``-namespaced (sandbox-passthrough) module, +so the object the worker's activity sees is the same one the module-level +``tool_as_activity(...)`` / ``TemporalBackend(...)`` call populated. They hold +worker-wide wiring, not per-workflow state. +""" + +from __future__ import annotations + +import uuid as _uuid +import warnings +from datetime import timedelta +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, Mapping + +from temporalio import activity as activity_mod +from temporalio import workflow +from temporalio.contrib.deepagents import _activity, _serde + +# LangChain is a runtime dependency of the *tool seam*, but importing this module +# must not require it (the plugin imports it just to read tool defaults). So the +# ``langchain_core.tools`` symbols are imported lazily inside the functions that +# actually build tools; ``from __future__ import annotations`` keeps the type +# hints below as strings so they never touch LangChain at import time. +if TYPE_CHECKING: + from langchain_core.tools import BaseTool + + +# --------------------------------------------------------------------------- +# Tool registry (worker-side execution targets) +# --------------------------------------------------------------------------- + +_TOOL_REGISTRY: dict[str, "BaseTool"] = {} +_BACKEND_REGISTRY: dict[str, Any] = {} + +# Worker-wide default activity options for tools wrapped with tool_as_activity, +# set by the plugin. Not per-workflow state: fixed for the worker's lifetime, and +# this module is sandbox-passthrough so the workflow sees the configured value. +_tool_defaults: dict[str, Any] = {} + + +def set_tool_defaults(options: Any) -> None: + """Install the plugin's default ``tool_activity_options`` (called by the plugin). + + Accepts a single ``ActivityConfig`` or a ``Mapping[tool_name, ActivityConfig]``. + """ + _tool_defaults.clear() + if options: + _tool_defaults["__value__"] = options + + +def _resolve_tool_options( + tool_name: str, instance_options: Mapping[str, Any] | None +) -> dict[str, Any]: + """Merge plugin tool defaults (possibly per-tool) with a per-call override.""" + opts: dict[str, Any] = {} + default = _tool_defaults.get("__value__") + if isinstance(default, Mapping): + per_tool = default.get(tool_name) + if isinstance(per_tool, Mapping): + opts.update(per_tool) + elif not any(isinstance(v, Mapping) for v in default.values()): + opts.update(default) + if instance_options: + opts.update(instance_options) + return opts + + +# Names of tools that route through Temporal (activity_as_tool / tool_as_activity), +# used to warn about unwrapped non-builtin tools at workflow build time. +_ROUTED_TOOL_NAMES: set[str] = set() + +# Deep Agents' built-in tools run in-workflow (pure state mutations); they are +# not expected to be wrapped, so they never trigger the unwrapped-tool warning. +# These are the LLM-facing TOOL names (``read_file``, ``write_file``, …) — +# NOT the backend protocol method names in ``_BACKEND_OPS`` (``read``, +# ``aread``, …); the two namespaces intentionally differ. +_BUILTIN_TOOL_NAMES = frozenset( + { + "write_todos", + "ls", + "read_file", + "write_file", + "edit_file", + "glob", + "grep", + "execute", + "task", + } +) + + +def register_tool(tool: BaseTool) -> None: + """Record ``tool`` so :meth:`DeepAgentActivities.invoke_tool` can run it.""" + _TOOL_REGISTRY[tool.name] = tool + + +def warn_unwrapped_tools(tools: Any) -> None: + """Warn once per unwrapped, non-builtin tool passed to ``create_deep_agent``. + + Running a tool in-workflow is only safe if it is pure/deterministic. The + Workflow-vs-Activity choice must be conscious, so any user tool that was not + routed through :func:`tool_as_activity` / :func:`activity_as_tool` gets a + construction-time warning rather than silently executing in the workflow. + """ + for tool in tools or (): + name = getattr(tool, "name", getattr(tool, "__name__", None)) + if name is None or name in _BUILTIN_TOOL_NAMES or name in _ROUTED_TOOL_NAMES: + continue + warnings.warn( + f"Tool {name!r} is passed to create_deep_agent unwrapped and will run " + f"inside the workflow. That is only safe if it is pure/deterministic. " + f"If it does I/O, wrap it with tool_as_activity(...) or expose an " + f"existing activity with activity_as_tool(...).", + stacklevel=3, + ) + + +def get_registered_tool(name: str) -> BaseTool | None: + """Look up a tool registered by :func:`register_tool`.""" + return _TOOL_REGISTRY.get(name) + + +def register_backend(ref: str, backend: Any) -> None: + """Record a backend so :meth:`DeepAgentActivities.backend_op` can reach it.""" + _BACKEND_REGISTRY[ref] = backend + + +def registered_backends() -> dict[str, Any]: + """Return the live backend registry (read by the plugin at worker build).""" + return _BACKEND_REGISTRY + + +# --------------------------------------------------------------------------- +# activity_as_tool +# --------------------------------------------------------------------------- + + +def activity_as_tool( + activity: Callable, + *, + start_to_close_timeout: timedelta, + name: str | None = None, + description: str | None = None, + retry_policy: Any = None, + summary: str | None = None, +) -> BaseTool: + """Expose an existing Temporal activity as a Deep Agents tool. + + The returned tool advertises the activity's argument schema to the model and, + when called in-workflow, dispatches to the activity via + ``workflow.execute_activity`` — Temporal owns its retries and timeout. + + Args: + activity: A function decorated with ``@activity.defn``. + start_to_close_timeout: Required per-call timeout for the activity. + name: Override the tool name advertised to the model (defaults to + the activity definition name). + description: Override the tool description advertised to the model + (defaults to the activity docstring). + retry_policy: Optional Temporal retry policy for the activity. + summary: Optional ``summary=`` recorded on each activity invocation. + """ + with workflow.unsafe.imports_passed_through(): + from langchain_core.tools import StructuredTool + + defn = activity_mod._Definition.from_callable(activity) + if defn is None: + raise ValueError( + "activity_as_tool requires a function decorated with @activity.defn; " + f"{getattr(activity, '__name__', activity)!r} is not an activity." + ) + tool_name = name or defn.name + if tool_name is None: + raise ValueError( + "activity_as_tool requires a named activity (dynamic activities " + "have no definition name); pass name= explicitly." + ) + tool_desc = description or (activity.__doc__ or f"Temporal activity {tool_name}.") + act_summary = summary or f"tool:{tool_name}" + + # Temporal activities take a single positional argument. StructuredTool infers + # the model-facing schema from ``_run``'s signature, so we mirror the activity's + # own parameter names onto ``_run`` (via ``@wraps``) and then collapse the + # keyword call the model produces back into that single positional payload. + import inspect + + params = [ + p for p in inspect.signature(activity).parameters if p not in ("self", "cls") + ] + + @wraps(activity) + async def _run(*args: Any, **kwargs: Any) -> Any: + if args and not kwargs: + payload: Any = args[0] if len(args) == 1 else list(args) + elif len(params) == 1 and len(kwargs) == 1: + # Single-argument activity: pass the value directly, not {"arg": value}. + payload = next(iter(kwargs.values())) + else: + payload = kwargs + return await workflow.execute_activity( + activity, + payload, + start_to_close_timeout=start_to_close_timeout, + retry_policy=retry_policy, + summary=act_summary, + ) + + _ROUTED_TOOL_NAMES.add(tool_name) + return StructuredTool.from_function( + coroutine=_run, + name=tool_name, + description=tool_desc, + ) + + +# --------------------------------------------------------------------------- +# tool_as_activity +# --------------------------------------------------------------------------- + + +def tool_as_activity( + tool: BaseTool | Callable, + *, + start_to_close_timeout: timedelta, + activity_options: Mapping[str, Any] | None = None, +) -> BaseTool: + """Wrap a LangChain tool / callable so its execution runs as an activity. + + The underlying tool is registered on the worker; the returned tool keeps the + same name and argument schema (so the model's calls are unchanged) but, when + invoked in-workflow, dispatches ``deepagents.invoke_tool`` instead of running + the tool body inline. + """ + with workflow.unsafe.imports_passed_through(): + from langchain_core.tools import BaseTool, StructuredTool + + base_tool: BaseTool + if isinstance(tool, BaseTool): + base_tool = tool + else: + base_tool = StructuredTool.from_function( + tool if not _is_coroutine(tool) else None, + coroutine=tool if _is_coroutine(tool) else None, + ) + register_tool(base_tool) + + tool_name = base_tool.name + opts = _resolve_tool_options(tool_name, activity_options) + opts.setdefault("start_to_close_timeout", start_to_close_timeout) + + async def _run(**kwargs: Any) -> Any: + from temporalio.contrib.deepagents.workflow import call_tool + + tool_call_id = kwargs.pop("__tool_call_id__", None) or workflow.uuid4().hex + activity_input = _activity.ToolActivityInput( + tool_name=tool_name, + tool_call_id=tool_call_id, + args=kwargs, + ) + output = await call_tool( + activity_input, + summary=f"tool:{tool_name}", + **opts, + ) + return _serde.load_object(output.message) + + _ROUTED_TOOL_NAMES.add(tool_name) + return StructuredTool( + name=tool_name, + description=base_tool.description, + args_schema=base_tool.args_schema, # type: ignore[arg-type] + coroutine=_run, + ) + + +def _is_coroutine(fn: Any) -> bool: + import inspect + + return inspect.iscoroutinefunction(fn) + + +# --------------------------------------------------------------------------- +# TemporalBackend +# --------------------------------------------------------------------------- + +# Backend METHOD names (deepagents ``BackendProtocol`` + +# ``SandboxBackendProtocol``) whose calls must cross the activity boundary: +# every sync I/O method, its async ``a``-prefixed twin, and the sandbox/shell +# execute pair. The async twins are load-bearing — deepagents' filesystem +# middleware drives backends through ``als``/``aread``/``awrite``/… — so +# intercepting only sync names lets an agent's built-in file tools run I/O +# in-workflow. These are backend PROTOCOL method names, distinct from the +# LLM-facing tool names in ``_BUILTIN_TOOL_NAMES`` (``read_file`` etc.). +_BACKEND_OPS = ( + # Sync protocol surface. + "ls", + "ls_info", + "read", + "write", + "edit", + "glob", + "glob_info", + "grep", + "grep_raw", + "download_files", + "upload_files", + "execute", + # Async twins (what FilesystemMiddleware actually calls). + "als", + "als_info", + "aread", + "awrite", + "aedit", + "aglob", + "aglob_info", + "agrep", + "agrep_raw", + "adownload_files", + "aupload_files", + "aexecute", +) + + +class TemporalBackend: + """Route a real-I/O backend's operations through Temporal activities. + + Wrap a ``FilesystemBackend`` / ``LocalShellBackend`` / ``StoreBackend`` / + ``CompositeBackend`` so each file or shell operation becomes a durable + ``deepagents.backend_op`` activity instead of touching disk / shell from the + workflow. State-only backends (``StateBackend``) need no wrapping — they are + pure workflow state and run in-workflow. + + Unknown attribute access is forwarded to the inner backend so backend + metadata / configuration the agent reads (but that does no I/O) still works. + """ + + def __init__( + self, + inner: Any, + *, + activity_options: Mapping[str, Any] | None = None, + ) -> None: + """Wrap ``inner`` so its I/O ops dispatch as durable activities.""" + self._inner = inner + # A deterministic id: workflow.uuid4() is seeded per-run, so the ref is + # identical across replays (unlike id(inner)). Falls back to a plain uuid + # when a backend is wrapped outside a workflow (e.g. in a plain test). + if workflow.in_workflow(): + self._ref = f"backend:{workflow.uuid4().hex}" + else: + self._ref = f"backend:{_uuid.uuid4().hex}" + self._opts: dict[str, Any] = dict(activity_options or {}) + self._opts.setdefault("start_to_close_timeout", timedelta(minutes=1)) + register_backend(self._ref, inner) + + async def _dispatch(self, op: str, *args: Any, **kwargs: Any) -> Any: + from temporalio.contrib.deepagents.workflow import call_backend_op + + activity_input = _activity.BackendOpInput( + backend_ref=self._ref, + op=op, + args=list(args), + kwargs=dict(kwargs), + ) + output = await call_backend_op( + activity_input, + summary=f"backend:{op}", + **self._opts, + ) + return _serde.load_backend_result(output.result) + + def __getattr__(self, name: str) -> Any: + """Bound-method access for a known I/O op returns an activity dispatcher. + + Everything else forwards to the inner backend unchanged. + """ + if name in _BACKEND_OPS: + + async def _op(*args: Any, **kwargs: Any) -> Any: + return await self._dispatch(name, *args, **kwargs) + + return _op + return getattr(self._inner, name) diff --git a/temporalio/contrib/deepagents/py.typed b/temporalio/contrib/deepagents/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/temporalio/contrib/deepagents/testing.py b/temporalio/contrib/deepagents/testing.py new file mode 100644 index 000000000..136e6a975 --- /dev/null +++ b/temporalio/contrib/deepagents/testing.py @@ -0,0 +1,139 @@ +"""Test helpers for users adopting :class:`DeepAgentsPlugin`. + +Unit-testing a Deep Agent under Temporal should not require a live LLM endpoint +or a paid API key. This module ships: + +* :class:`FakeModel` — a real ``BaseChatModel`` returning scripted replies (plain + text or full ``AIMessage``s carrying ``tool_calls``), cycling when exhausted; +* :func:`fake_model_factory` — a one-liner for the common text-only case; +* :func:`mock_model_provider` — a ``model_provider`` (name → model) you pass to + ``DeepAgentsPlugin(model_provider=...)`` so the model activity runs offline; +* :class:`MockTool` — a scripted ``BaseTool`` for exercising the tool seam. + +Importing this module has no process-wide side effects. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional, Sequence, Union + +from langchain_core.callbacks import ( + AsyncCallbackManagerForLLMRun, + CallbackManagerForLLMRun, +) +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import BaseTool +from pydantic import PrivateAttr + +__all__ = ["FakeModel", "fake_model_factory", "mock_model_provider", "MockTool"] + +Response = Union[str, AIMessage] + + +class FakeModel(BaseChatModel): + """A ``BaseChatModel`` returning scripted responses, for offline tests. + + Args: + responses: Replies returned one per call, cycling when exhausted. Each is + either a string (becomes an ``AIMessage``) or an ``AIMessage`` (so you + can script ``tool_calls`` to drive the agent's tool path). + """ + + responses: List[Any] + _cursor: int = PrivateAttr(default=0) + + def __init__(self, responses: Sequence[Response], **kwargs: Any) -> None: + """Validate and store the scripted responses.""" + resp = list(responses) + if not resp: + raise ValueError("FakeModel needs at least one scripted response.") + super().__init__(responses=resp, **kwargs) # type: ignore[call-arg] + + @property + def _llm_type(self) -> str: + return "temporalio-deepagents-fake-model" + + def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> "FakeModel": + """Ignore the tool set; the fake just replays its script. + + Returning ``self`` keeps ``model.bind_tools(...)`` chainable like a + real model. + """ + return self + + def _next(self) -> AIMessage: + item = self.responses[self._cursor % len(self.responses)] + self._cursor += 1 + return item if isinstance(item, AIMessage) else AIMessage(content=item) + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Optional[CallbackManagerForLLMRun] = None, + **kwargs: Any, + ) -> ChatResult: + return ChatResult(generations=[ChatGeneration(message=self._next())]) + + async def _agenerate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Optional[AsyncCallbackManagerForLLMRun] = None, + **kwargs: Any, + ) -> ChatResult: + return ChatResult(generations=[ChatGeneration(message=self._next())]) + + +def fake_model_factory(responses: Sequence[Response]) -> FakeModel: + """One-liner scripted fake chat model. + + Example:: + + model = fake_model_factory(["The capital of France is Paris."]) + """ + return FakeModel(responses) + + +def mock_model_provider( + responses: Sequence[Response], +) -> Callable[[str], FakeModel]: + """A ``model_provider`` that hands out the scripted responses one call at a time. + + Each model activity invocation (main agent, a sub-agent, a follow-up turn + after a tool call) advances through ``responses`` and cycles when exhausted, + so a multi-turn agent can be scripted deterministically. The model activity + builds a fresh model per call, so the cursor lives on the provider closure + (shared for the worker's lifetime) rather than on any one model instance. + + Pass to the plugin so the model activity runs offline:: + + plugin = DeepAgentsPlugin(model_provider=mock_model_provider(["Paris."])) + """ + resp = list(responses) + if not resp: + raise ValueError("mock_model_provider needs at least one response.") + cursor = {"i": 0} + + def provider(_model_name: str) -> FakeModel: + reply = resp[cursor["i"] % len(resp)] + cursor["i"] += 1 + return FakeModel([reply]) + + return provider + + +class MockTool(BaseTool): + """A scripted ``BaseTool`` whose call returns a fixed value, for tests.""" + + name: str = "mock_tool" + description: str = "A mock tool that returns a scripted result." + result: Any = "ok" + + def _run(self, *args: Any, **kwargs: Any) -> Any: + return self.result + + async def _arun(self, *args: Any, **kwargs: Any) -> Any: + return self.result diff --git a/temporalio/contrib/deepagents/workflow.py b/temporalio/contrib/deepagents/workflow.py new file mode 100644 index 000000000..b7798db63 --- /dev/null +++ b/temporalio/contrib/deepagents/workflow.py @@ -0,0 +1,252 @@ +"""Workflow-side surface: the failure type, the dispatch helpers, and the runner. + +Everything here runs *inside* the workflow. The dispatch helpers +(:func:`call_model` / :func:`call_tool` / :func:`call_backend_op`) are the single +choke point through which the in-workflow model / tool / backend stubs reach +their activities; they also consult the continue-as-new result cache so work +done before a ``continue_as_new`` is reused rather than repeated after it. + +:func:`run_deep_agent` is the optional driver that adds continue-as-new +state-carry around a native ``agent.ainvoke(...)`` — plain ``agent.ainvoke(...)`` +still works without it. +""" + +from __future__ import annotations + +import warnings +from typing import Any, Mapping + +from temporalio import workflow +from temporalio.contrib.deepagents import _activity, _serde +from temporalio.exceptions import ApplicationError + +# Reserved key under which the CAN result cache rides inside a state snapshot. +_CACHE_KEY = "__temporal_cache__" + +# Checkpointer classes that keep their state in the workflow's own memory and are +# therefore rehydrated for free by deterministic replay. Anything else does its +# own I/O and is not replay-safe from inside the workflow. +_IN_WORKFLOW_SAVERS = frozenset({"InMemorySaver", "MemorySaver"}) + + +def warn_durable_checkpointer(checkpointer: Any) -> None: + """Warn when a user hands ``create_deep_agent`` a durable checkpointer. + + The Deep Agents loop runs inside the workflow, so a checkpointer that does + its own database / disk I/O would run that I/O from workflow code — not + replay-safe. We respect the user's choice (a warning, not a hard failure), + and point them at the durability path that *is* safe: the default in-workflow + ``InMemorySaver`` rehydrated by replay, plus + :func:`run_deep_agent` with ``continue_as_new_after`` for long conversations. + """ + if checkpointer is None: + return + if type(checkpointer).__name__ in _IN_WORKFLOW_SAVERS: + return + warnings.warn( + f"create_deep_agent received a durable checkpointer " + f"{type(checkpointer).__name__!r}. The agent loop runs inside the " + f"workflow, so this checkpointer's I/O would run from workflow code, " + f"which is not replay-safe. Prefer the default in-workflow InMemorySaver " + f"(rehydrated by replay) plus run_deep_agent(continue_as_new_after=...) " + f"for long-conversation durability.", + stacklevel=3, + ) + + +class DeepAgentsWorkflowError(ApplicationError): + """Raised for non-retryable Deep Agents failures surfaced in the workflow. + + This is the type registered in the plugin's + ``workflow_failure_exception_types``, so a model / tool failure that Temporal + has exhausted (or an invalid agent configuration) fails the workflow with a + stable ``ApplicationError.type`` — never a stringified peer exception. + """ + + TYPE = "deepagents.DeepAgentsWorkflowError" + + def __init__(self, message: str, *, non_retryable: bool = True) -> None: + """Construct the error with the plugin's stable failure ``type``.""" + super().__init__(message, type=self.TYPE, non_retryable=non_retryable) + + +# --------------------------------------------------------------------------- +# Dispatch helpers (the model / tool / backend choke point) +# --------------------------------------------------------------------------- + + +async def call_model( + activity_name: str, + activity_input: _activity.ModelActivityInput, + *, + summary: str, + **opts: Any, +) -> _activity.ModelActivityOutput: + """Dispatch one model call, reusing a cached result across continue-as-new.""" + key = _serde.cache_key( + "model", + activity_input.model_name, + [activity_input.messages, activity_input.tool_schemas], + ) + hit, cached = _serde.cache_lookup(key) + if hit: + return _activity.ModelActivityOutput(message=cached) + output = await workflow.execute_activity( + activity_name, + activity_input, + result_type=_activity.ModelActivityOutput, + summary=summary, + **opts, + ) + _serde.cache_put(key, output.message) + return output + + +async def call_tool( + activity_input: _activity.ToolActivityInput, + *, + summary: str, + **opts: Any, +) -> _activity.ToolActivityOutput: + """Dispatch one tool call, reusing a cached result across continue-as-new.""" + key = _serde.cache_key("tool", activity_input.tool_name, activity_input.args) + hit, cached = _serde.cache_lookup(key) + if hit: + return _activity.ToolActivityOutput(message=cached) + output = await workflow.execute_activity( + _activity.INVOKE_TOOL, + activity_input, + result_type=_activity.ToolActivityOutput, + summary=summary, + **opts, + ) + _serde.cache_put(key, output.message) + return output + + +async def call_backend_op( + activity_input: _activity.BackendOpInput, + *, + summary: str, + **opts: Any, +) -> _activity.BackendOpOutput: + """Dispatch one backend op, reusing a cached result across continue-as-new.""" + key = _serde.cache_key( + f"backend:{activity_input.backend_ref}", + activity_input.op, + [activity_input.args, activity_input.kwargs], + ) + hit, cached = _serde.cache_lookup(key) + if hit: + return _activity.BackendOpOutput(result=cached) + output = await workflow.execute_activity( + _activity.BACKEND_OP, + activity_input, + result_type=_activity.BackendOpOutput, + summary=summary, + **opts, + ) + _serde.cache_put(key, output.result) + return output + + +# --------------------------------------------------------------------------- +# run_deep_agent (continue-as-new state carry) +# --------------------------------------------------------------------------- + + +def _merge_snapshot(input: Any, snapshot: Mapping[str, Any]) -> Any: + """Prepend a snapshot's carried messages onto the next turn's input.""" + raw_prior: Any = snapshot.get("messages") or [] + prior = list(raw_prior) + if not prior: + return input + if isinstance(input, Mapping): + merged = dict(input) + raw_next: Any = input.get("messages") or [] + merged["messages"] = [*prior, *list(raw_next)] + return merged + return {"messages": [*prior, *_as_message_list(input)]} + + +def _as_message_list(input: Any) -> list[Any]: + if isinstance(input, (list, tuple)): + return list(input) + return [input] + + +async def run_deep_agent( + agent: Any, + input: Any, + *, + continue_as_new_after: int | None = None, + state_snapshot: Mapping[str, Any] | None = None, +) -> Any: + """Drive ``agent.ainvoke(input)`` with optional continue-as-new state carry. + + Without ``continue_as_new_after`` this is a thin wrapper over + ``agent.ainvoke``. With it, once the workflow history passes the threshold the + completed turn's state (messages + the model/tool result cache) is snapshotted + and carried into a fresh run via ``workflow.continue_as_new``, so long + conversations do not accumulate unbounded history. + + The enclosing ``@workflow.run`` method must accept the continued call — i.e. + its signature is ``(input, state_snapshot=None)`` — because that is how the + carried state is threaded into the next run. + """ + # Resume path: rehydrate the result cache and fold carried messages in. + if state_snapshot is not None: + _serde.set_result_cache(dict(state_snapshot.get(_CACHE_KEY) or {})) + input = _merge_snapshot(input, state_snapshot) + else: + _serde.set_result_cache({}) + + try: + result = await agent.ainvoke(input) + except ApplicationError: + raise + except Exception as exc: + # If the framework wrapped a Temporal failure, surface the registered + # workflow-failure type rather than the framework's generic exception. + cause = exc.__cause__ + if cause is not None and workflow.is_failure_exception(cause): + raise DeepAgentsWorkflowError(f"Deep Agents run failed: {exc}") from cause + raise + + if ( + continue_as_new_after is not None + and workflow.info().get_current_history_length() >= continue_as_new_after + and _has_pending_work(result) + ): + snapshot = { + "messages": _extract_messages(result), + _CACHE_KEY: _serde.result_cache_snapshot() or {}, + } + # ``continue_as_new`` threads positional args into the next run via + # ``args=``; the enclosing ``@workflow.run`` receives them as + # ``(input, state_snapshot)``. + workflow.continue_as_new(args=[input, snapshot]) + + return result + + +def _extract_messages(result: Any) -> list[Any]: + if isinstance(result, Mapping): + raw: Any = result.get("messages") or [] + return list(raw) + return [] + + +def _has_pending_work(result: Any) -> bool: + """True when the agent left unfinished todos worth carrying past a CAN. + + A finished single-shot run has no pending todos, so this returns False and the + driver returns the result instead of looping on continue-as-new forever. + """ + if isinstance(result, Mapping): + todos: Any = result.get("todos") or [] + return any( + isinstance(t, Mapping) and t.get("status") not in ("completed", "done") + for t in todos + ) + return False diff --git a/tests/contrib/deepagents/__init__.py b/tests/contrib/deepagents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/contrib/deepagents/helpers.py b/tests/contrib/deepagents/helpers.py new file mode 100644 index 000000000..eb5ba295b --- /dev/null +++ b/tests/contrib/deepagents/helpers.py @@ -0,0 +1,16 @@ +"""Shared helpers for the Deep Agents plugin test suite.""" + +from collections import Counter + +from temporalio.api.enums.v1 import EventType +from temporalio.client import WorkflowHandle + + +async def count_scheduled_activities(handle: WorkflowHandle) -> Counter: + """Count ``ActivityTaskScheduled`` events by activity-type name.""" + counts: Counter = Counter() + async for event in handle.fetch_history_events(): + if event.event_type == EventType.EVENT_TYPE_ACTIVITY_TASK_SCHEDULED: + name = event.activity_task_scheduled_event_attributes.activity_type.name + counts[name] += 1 + return counts diff --git a/tests/contrib/deepagents/test_backends.py b/tests/contrib/deepagents/test_backends.py new file mode 100644 index 000000000..18fba4969 --- /dev/null +++ b/tests/contrib/deepagents/test_backends.py @@ -0,0 +1,212 @@ +"""``TemporalBackend`` routes real-I/O backend ops through activities. + +A backend that touches disk or a shell must not run its operations from workflow +code. ``TemporalBackend`` wraps such a backend so each op becomes a +``deepagents.backend_op`` activity. The wrapped backend here is a plain object +(no LangChain / deepagents needed), so this boots a real server and proves the +op crosses the activity boundary. + +A state-only backend needs no wrapping — that path is covered against the real +``deepagents.StateBackend`` when it is importable. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import timedelta +from typing import cast + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) +pytest.importorskip("deepagents") +pytest.importorskip("langchain_core") + +from temporalio import workflow +from temporalio.contrib.deepagents import DeepAgentsPlugin, TemporalBackend +from temporalio.worker import Worker +from tests.contrib.deepagents.helpers import count_scheduled_activities + +BACKEND_OP = "deepagents.backend_op" + + +class RecordingBackend: + """A minimal backend doing 'real' work off-workflow, exposing both halves + of the deepagents backend protocol: a sync op (``read``) and its async + twin (``aread``). The async twin is the regression-critical case — + deepagents' filesystem middleware calls ``aread``/``awrite``/…, and an + earlier op list intercepted only sync names, so agent-driven file tools + ran their I/O in-workflow.""" + + def read(self, file_path: str) -> str: + return f"contents of {file_path}" + + async def aread(self, file_path: str) -> str: + return f"acontents of {file_path}" + + +@workflow.defn +class BackendWorkflow: + @workflow.run + async def run(self, path: str) -> str: + backend = TemporalBackend( + RecordingBackend(), + activity_options={"start_to_close_timeout": timedelta(seconds=10)}, + ) + sync_out = await backend.read(path) + async_out = await backend.aread(path) + return f"{sync_out}|{async_out}" + + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + from deepagents.backends import FilesystemBackend, StateBackend + from deepagents.backends.protocol import BackendProtocol + + +# A state-only backend is pure workflow state and must NOT schedule an activity. +@workflow.defn +class StateBackendWorkflow: + @workflow.run + async def run(self) -> bool: + backend = StateBackend() + # Merely holding a StateBackend schedules no activity; it is not wrapped. + return backend is not None + + +# The full agent-level seam: a REAL Deep Agent whose BUILT-IN file tools drive a +# REAL FilesystemBackend through TemporalBackend. This is the path a fake-backend +# test cannot cover: deepagents' filesystem middleware calls the ASYNC protocol +# (`awrite` / `aread`), and the ops return protocol dataclasses (WriteResult / +# ReadResult) that must survive the activity boundary as real objects — the +# middleware reads their attributes in-workflow. +@workflow.defn +class FilesystemAgentWorkflow: + @workflow.run + async def run(self, root_dir: str) -> str: + backend = TemporalBackend( + FilesystemBackend(root_dir=root_dir, virtual_mode=True), + activity_options={"start_to_close_timeout": timedelta(seconds=10)}, + ) + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + # TemporalBackend satisfies the protocol structurally via + # __getattr__ forwarding, which nominal type checking can't see. + backend=cast(BackendProtocol, cast(object, backend)), + system_prompt="Write the note, read it back, then report it.", + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": "Note 'hello' down."}]} + ) + return str(result["messages"][-1].content) + + +@pytest.mark.asyncio +async def test_temporal_backend_op_activity(env) -> None: + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-backend", + workflows=[BackendWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + BackendWorkflow.run, + "notes.txt", + id=f"da-backend-{uuid.uuid4()}", + task_queue="da-backend", + ) + out = await handle.result() + + assert out == "contents of notes.txt|acontents of notes.txt" + counts = await count_scheduled_activities(handle) + # One activity per op — the sync read AND the async aread both cross. + assert counts[BACKEND_OP] == 2, counts + + +@pytest.mark.asyncio +async def test_state_backend_in_workflow(env) -> None: + # A state-only backend is pure workflow state and must NOT schedule an + # activity. Exercised against the real StateBackend when deepagents is present. + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-state-backend", + workflows=[StateBackendWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + StateBackendWorkflow.run, + id=f"da-state-backend-{uuid.uuid4()}", + task_queue="da-state-backend", + ) + assert await handle.result() is True + counts = await count_scheduled_activities(handle) + assert counts[BACKEND_OP] == 0, counts + + +@pytest.mark.asyncio +async def test_agent_builtin_file_tools_route_backend_ops(env, tmp_path) -> None: + """An unmodified agent's built-in write_file/read_file tools cross the + activity boundary when the backend is TemporalBackend-wrapped — under + ``max_cached_workflows=0``, so every workflow task replays from history. + + Regression: an earlier op list intercepted only sync method names, so the + middleware's async calls (`awrite`/`aread`) forwarded to the inner backend + and ran real disk I/O in-workflow. This test fails if that recurs, if the + protocol result dataclasses stop surviving the activity boundary, or if + replay diverges. + """ + from langchain_core.messages import AIMessage # real lib; guarded above + + 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") + from temporalio.contrib.deepagents.testing import mock_model_provider + + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider([write_turn, read_turn, final]), + ) + async with Worker( + env.client, + task_queue="da-fs-agent", + workflows=[FilesystemAgentWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await env.client.start_workflow( + FilesystemAgentWorkflow.run, + str(tmp_path), + id=f"da-fs-agent-{uuid.uuid4()}", + task_queue="da-fs-agent", + ) + out = await handle.result() + + assert "hello" in out + # The write really happened on disk — in the activity, not the workflow. + assert (tmp_path / "notes.txt").read_text() == "hello" + counts = await count_scheduled_activities(handle) + # Exactly one backend_op per file tool call (awrite + aread), three model turns. + assert counts[BACKEND_OP] == 2, counts + assert counts["deepagents.invoke_model"] == 3, counts diff --git a/tests/contrib/deepagents/test_checkpointer.py b/tests/contrib/deepagents/test_checkpointer.py new file mode 100644 index 000000000..ddbd47a4f --- /dev/null +++ b/tests/contrib/deepagents/test_checkpointer.py @@ -0,0 +1,93 @@ +"""Checkpointing follows the ``contrib.langgraph`` precedent. + +The zero-config default is an in-workflow ``InMemorySaver`` rehydrated by +deterministic replay; no bespoke checkpointer adapter ships. A user-supplied +durable checkpointer would run its own I/O from workflow code, which is not +replay-safe, so the plugin warns (respecting the choice rather than hard-failing) +and points at the snapshot + continue-as-new path. +""" + +from __future__ import annotations + +import sys +import uuid +import warnings + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) +pytest.importorskip("deepagents") +pytest.importorskip("langchain_core") + +from temporalio import workflow +from temporalio.contrib.deepagents.workflow import warn_durable_checkpointer + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + + +class _DurableSaver: + """Stand-in for a checkpointer that does its own database I/O.""" + + +class InMemorySaver: + """Same class name LangGraph's in-workflow saver uses.""" + + +@workflow.defn +class CheckpointWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + agent = create_deep_agent(model="fake:model") + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": prompt}]} + ) + return str(result["messages"][-1].content) + + +def test_durable_checkpointer_warns() -> None: + # None and in-workflow savers are silent... + with warnings.catch_warnings(record=True) as records: + warnings.simplefilter("always") + warn_durable_checkpointer(None) + warn_durable_checkpointer(InMemorySaver()) + assert not records, [str(r.message) for r in records] + + # ...a durable saver warns (respect the choice, do not hard-fail). + with pytest.warns(UserWarning, match="durable checkpointer"): + warn_durable_checkpointer(_DurableSaver()) + + +@pytest.mark.asyncio +async def test_default_saver_rehydrates(env) -> None: + # Against the real deepagents default (in-workflow InMemorySaver): the agent + # runs and its recorded history replays cleanly, proving replay rehydrates + # the in-workflow checkpoint state with no external checkpointer. + pytest.importorskip("deepagents") + pytest.importorskip("langchain_core") + + from temporalio.contrib.deepagents import DeepAgentsPlugin + from temporalio.contrib.deepagents.testing import mock_model_provider + from temporalio.worker import Replayer, Worker + + plugin = DeepAgentsPlugin(model_provider=mock_model_provider(["Checkpointed."])) + async with Worker( + env.client, + task_queue="da-ckpt", + workflows=[CheckpointWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + CheckpointWorkflow.run, + "hello", + id=f"da-ckpt-{uuid.uuid4()}", + task_queue="da-ckpt", + ) + await handle.result() + history = await handle.fetch_history() + + await Replayer( + workflows=[CheckpointWorkflow], plugins=[DeepAgentsPlugin()] + ).replay_workflow(history) diff --git a/tests/contrib/deepagents/test_continue_as_new.py b/tests/contrib/deepagents/test_continue_as_new.py new file mode 100644 index 000000000..15a788b8d --- /dev/null +++ b/tests/contrib/deepagents/test_continue_as_new.py @@ -0,0 +1,96 @@ +"""Continue-as-new state carry for long-running Deep Agents. + +``run_deep_agent(continue_as_new_after=...)`` keeps a long conversation from +bloating workflow history: once the current turn finishes past the threshold and +there is still pending work, it snapshots the accumulated messages plus the +model/tool result cache and continues into a fresh run. These tests use a plain +fake agent (no LangChain needed) so they boot a real Temporal server and exercise +the continue-as-new machinery end to end. +""" + +from __future__ import annotations + +import sys +import uuid +from typing import Any + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) +from temporalio import workflow +from temporalio.contrib.deepagents import DeepAgentsPlugin, _serde, run_deep_agent +from temporalio.worker import Worker + + +class FakeAgent: + """A stand-in compiled agent that appends a step and reports a todo. + + It is *not* a LangChain object — it just satisfies the ``ainvoke`` shape + ``run_deep_agent`` drives, so the continue-as-new path can be tested 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": "work", "status": "completed" if done else "pending"} + ], + } + + +@workflow.defn +class ContinueAsNewWorkflow: + @workflow.run + async def run(self, input: dict, state_snapshot: dict | None = None) -> dict: + # Threshold of 1 means: continue-as-new as soon as there is pending work, + # which the fake agent reports until the conversation reaches 3 messages. + return await run_deep_agent( + FakeAgent(), + input, + continue_as_new_after=1, + state_snapshot=state_snapshot, + ) + + +@pytest.mark.asyncio +async def test_can_threshold_and_cache(env) -> None: + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-can", + workflows=[ContinueAsNewWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + ContinueAsNewWorkflow.run, + {"messages": ["start"]}, + id=f"da-can-{uuid.uuid4()}", + task_queue="da-can", + ) + result = await handle.result() + + # The only way the conversation reaches >= 3 messages is if the snapshot from + # the pre-continue-as-new run was carried into the continued run and merged. + assert len(result["messages"]) >= 3, result + assert result["todos"][0]["status"] == "completed" + + +def test_state_snapshot_roundtrip() -> None: + # The result cache carried in a snapshot rehydrates to the same hits, so work + # done before a continue-as-new is reused, not recomputed, afterwards. + _serde.set_result_cache({}) + key = _serde.cache_key("model", "fake:model", [["m"], []]) + _serde.cache_put(key, {"dumped": "message"}) + snapshot = _serde.result_cache_snapshot() + assert snapshot and key in snapshot + + # Simulate the continued run: a fresh cache seeded from the snapshot. + _serde.set_result_cache(dict(snapshot)) + hit, value = _serde.cache_lookup(key) + assert hit and value == {"dumped": "message"} diff --git a/tests/contrib/deepagents/test_failures.py b/tests/contrib/deepagents/test_failures.py new file mode 100644 index 000000000..d3317a1ab --- /dev/null +++ b/tests/contrib/deepagents/test_failures.py @@ -0,0 +1,117 @@ +"""Error handling: retry classification and the workflow-failure type. + +Two dep-free paths run against a real server: the HTTP-error → Temporal retry +translation, and that a raised :class:`DeepAgentsWorkflowError` surfaces to the +client as a non-retryable failure with a stable ``ApplicationError.type`` (never +a stringified peer exception). The model-instance validation error needs +LangChain and guards on its import. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import timedelta + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) +from temporalio import workflow +from temporalio.client import WorkflowFailureError +from temporalio.contrib.deepagents import DeepAgentsPlugin, DeepAgentsWorkflowError +from temporalio.contrib.deepagents._activity import _translate_api_error +from temporalio.exceptions import ApplicationError +from temporalio.worker import Worker + + +class _FakeResponse: + def __init__(self, headers: dict) -> None: + self.headers = headers + + +class _FakeHTTPError(Exception): + def __init__(self, status_code: int, headers: dict | None = None) -> None: + super().__init__(f"HTTP {status_code}") + self.status_code = status_code + self.response = _FakeResponse(headers or {}) + + +def test_error_classification() -> None: + # 429 is retryable and honors the upstream Retry-After. + err = _translate_api_error(_FakeHTTPError(429, {"retry-after": "7"})) + assert isinstance(err, ApplicationError) + assert err.non_retryable is False + assert err.next_retry_delay == timedelta(seconds=7) + + # 400 is a client error: non-retryable. + e400 = _translate_api_error(_FakeHTTPError(400)) + assert isinstance(e400, ApplicationError) + assert e400.non_retryable is True + + # 503 is retryable by default... + e503 = _translate_api_error(_FakeHTTPError(503)) + assert isinstance(e503, ApplicationError) + assert e503.non_retryable is False + # ...unless the server explicitly says not to. + forced = _translate_api_error(_FakeHTTPError(503, {"x-should-retry": "false"})) + assert isinstance(forced, ApplicationError) + assert forced.non_retryable is True + + # retry-after-ms wins over retry-after when both are present. + ms = _translate_api_error( + _FakeHTTPError(429, {"retry-after-ms": "250", "retry-after": "7"}) + ) + assert isinstance(ms, ApplicationError) + assert ms.next_retry_delay == timedelta(milliseconds=250) + + # A non-HTTP exception is not recognized, so the caller falls through. + assert _translate_api_error(ValueError("nope")) is None + + +@workflow.defn +class FailingWorkflow: + @workflow.run + async def run(self) -> None: + raise DeepAgentsWorkflowError("deliberate non-retryable failure") + + +@pytest.mark.asyncio +async def test_workflow_failure_type(env) -> None: + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-fail", + workflows=[FailingWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + FailingWorkflow.run, + id=f"da-fail-{uuid.uuid4()}", + task_queue="da-fail", + ) + with pytest.raises(WorkflowFailureError) as excinfo: + await handle.result() + + cause = excinfo.value.cause + assert isinstance(cause, ApplicationError) + assert cause.type == "deepagents.DeepAgentsWorkflowError" + assert cause.non_retryable is True + + +@pytest.mark.asyncio +async def test_unwrappable_model_instance(env) -> None: + pytest.importorskip("langchain_core") + from langchain_core.language_models.fake_chat_models import FakeListChatModel + + # A string is auto-wrapped; a TemporalModel passes through; a live model + # instance is rejected at the workflow boundary with the typed failure. + from temporalio.contrib.deepagents import TemporalModel + from temporalio.contrib.deepagents._model import _wrap_model_arg + + assert isinstance(_wrap_model_arg("anthropic:claude"), TemporalModel) + tm = TemporalModel(model="anthropic:claude") + assert _wrap_model_arg(tm) is tm + with pytest.raises(DeepAgentsWorkflowError): + _wrap_model_arg(FakeListChatModel(responses=["hi"])) diff --git a/tests/contrib/deepagents/test_hitl.py b/tests/contrib/deepagents/test_hitl.py new file mode 100644 index 000000000..9015837d3 --- /dev/null +++ b/tests/contrib/deepagents/test_hitl.py @@ -0,0 +1,132 @@ +"""Human-in-the-loop: SDK-native interrupt mapped to Query + Update. + +``interrupt_on={...}`` makes Deep Agents pause before a guarded tool runs. With a +checkpointer configured, LangGraph does *not* raise out of ``ainvoke`` — it +returns the current state with an ``__interrupt__`` entry describing the pending +approval (verified against deepagents 0.6.12 / langchain 1.x). Because the loop +runs in the workflow, that pause surfaces directly in workflow code. The plugin's +recommended mapping — used here — is: detect the returned ``__interrupt__``, +expose its payload via a Query, and resume with a Workflow Update carrying the +human's decision through ``Command(resume=...)``. No shim exception is invented; +the native LangGraph resume protocol (``{"decisions": [{"type": ...}]}``) is used +as-is. + +State lives on the workflow instance (per-execution), which is the idiomatic +Temporal pattern for state shared between the run method and its handlers. +""" + +from __future__ import annotations + +import asyncio +import sys +import uuid +from datetime import timedelta +from typing import Any + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) + +pytest.importorskip("deepagents") +pytest.importorskip("langchain_core") +pytest.importorskip("langgraph") + +from temporalio import workflow # noqa: E402 +from temporalio.worker import Worker # noqa: E402 + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + from langchain_core.messages import AIMessage + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import InMemorySaver + from langgraph.types import Command + + from temporalio.contrib.deepagents import DeepAgentsPlugin, tool_as_activity + from temporalio.contrib.deepagents.testing import mock_model_provider + + +@workflow.defn +class HitlWorkflow: + def __init__(self) -> None: + self._interrupt: str | None = None + self._resume_value: 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} + } + payload: Any = {"messages": [{"role": "user", "content": f"Book {city}."}]} + result = await agent.ainvoke(payload, config=config) + # LangGraph returns (not raises) the pending approval under __interrupt__. + pending = result.get("__interrupt__") + if pending: + self._interrupt = str(getattr(pending[0], "value", pending[0])) + await workflow.wait_condition(lambda: self._resumed) + result = await agent.ainvoke( + Command(resume={"decisions": [{"type": self._resume_value}]}), + config=config, + ) + return result["messages"][-1].content + + @workflow.query + def pending_interrupt(self) -> str | None: + return self._interrupt + + @workflow.update + async def resume(self, decision: str) -> None: + self._resume_value = decision + self._resumed = True + + +@pytest.mark.asyncio +async def test_interrupt_query_then_resume(env) -> None: + approve = 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([approve, done])) + async with Worker( + env.client, + task_queue="da-hitl", + workflows=[HitlWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + HitlWorkflow.run, + "Rome", + id=f"da-hitl-{uuid.uuid4()}", + task_queue="da-hitl", + ) + + # Wait for the agent to hit the interrupt (surfaced via the Query), then + # approve via an Update. A bounded poll with a sleep, so a regression that + # never raises the interrupt fails fast instead of busy-spinning. + for _ in range(100): + if await handle.query(HitlWorkflow.pending_interrupt) is not None: + break + await asyncio.sleep(0.1) + else: + pytest.fail("workflow never surfaced the HITL interrupt via the query") + + await handle.execute_update(HitlWorkflow.resume, "approve") + out = await handle.result() + + assert "Rome" in out diff --git a/tests/contrib/deepagents/test_model_activity.py b/tests/contrib/deepagents/test_model_activity.py new file mode 100644 index 000000000..838763fcc --- /dev/null +++ b/tests/contrib/deepagents/test_model_activity.py @@ -0,0 +1,101 @@ +"""The model seam: every ``TemporalModel`` generation is one activity. + +These exercise the seam directly through ``TemporalModel`` (no full agent +needed), so they depend only on LangChain, not on deepagents. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import timedelta + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) + +pytest.importorskip("langchain_core") + +from temporalio import workflow # noqa: E402 +from temporalio.worker import Worker # noqa: E402 +from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 + +with workflow.unsafe.imports_passed_through(): + from langchain_core.messages import HumanMessage + + from temporalio.contrib.deepagents import DeepAgentsPlugin, TemporalModel + from temporalio.contrib.deepagents.testing import mock_model_provider + +INVOKE_MODEL = "deepagents.invoke_model" + + +@workflow.defn +class ModelWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + model = TemporalModel(model="fake:model") + message = await model.ainvoke([HumanMessage(content=prompt)]) + return str(message.content) + + +@workflow.defn +class ExplicitTimeoutWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + model = TemporalModel( + model="fake:model", + activity_options={"start_to_close_timeout": timedelta(seconds=20)}, + ) + message = await model.ainvoke([HumanMessage(content=prompt)]) + return str(message.content) + + +@pytest.mark.asyncio +async def test_model_call_is_activity(env) -> None: + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["The capital of France is Paris."]), + model_activity_options={"start_to_close_timeout": timedelta(seconds=30)}, + ) + async with Worker( + env.client, + task_queue="da-model", + workflows=[ModelWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + ModelWorkflow.run, + "What is the capital of France?", + id=f"da-model-{uuid.uuid4()}", + task_queue="da-model", + ) + out = await handle.result() + assert "Paris" in out + counts = await count_scheduled_activities(handle) + assert counts[INVOKE_MODEL] == 1, counts + + +@pytest.mark.asyncio +async def test_temporal_model_explicit(env) -> None: + # The explicit escape hatch routes through the same activity, with a + # per-model timeout override rather than the plugin default. + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["Bonjour."]), + ) + async with Worker( + env.client, + task_queue="da-model-explicit", + workflows=[ExplicitTimeoutWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + ExplicitTimeoutWorkflow.run, + "hi", + id=f"da-model-x-{uuid.uuid4()}", + task_queue="da-model-explicit", + ) + out = await handle.result() + assert out == "Bonjour." + counts = await count_scheduled_activities(handle) + assert counts[INVOKE_MODEL] == 1, counts diff --git a/tests/contrib/deepagents/test_native_e2e.py b/tests/contrib/deepagents/test_native_e2e.py new file mode 100644 index 000000000..8de4f78dc --- /dev/null +++ b/tests/contrib/deepagents/test_native_e2e.py @@ -0,0 +1,136 @@ +"""Cardinal end-to-end test: unmodified ``deepagents`` code, made durable. + +Builds a real Deep Agent with ``create_deep_agent(...)`` and drives it with +``agent.ainvoke(...)`` inside a ``@workflow.defn`` — the only addition is +``plugins=[DeepAgentsPlugin(...)]``. No user call to +``workflow.execute_activity``; the plugin routes model and tool calls to +activities under the hood. + +Guards on the ``deepagents`` / ``langchain_core`` imports (capability detection), +because they are the plugin's own runtime dependency and may be absent on a +docs-only checkout — there is no env-var gate. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import timedelta + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) + +pytest.importorskip("deepagents") +pytest.importorskip("langchain_core") + +import deepagents # noqa: E402,F401 (cardinal rule: import the SDK directly) + +from temporalio import workflow # noqa: E402 +from temporalio.worker import Worker # noqa: E402 +from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + from langchain_core.messages import AIMessage + + from temporalio.contrib.deepagents import DeepAgentsPlugin, tool_as_activity + from temporalio.contrib.deepagents.testing import mock_model_provider + +INVOKE_MODEL = "deepagents.invoke_model" +INVOKE_TOOL = "deepagents.invoke_tool" + + +@workflow.defn +class DeepAgentWorkflow: + @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 + + +@workflow.defn +class ToolLoopWorkflow: + @workflow.run + async def run(self, city: str) -> str: + def get_weather(city: str) -> str: + """Return the weather for a city.""" + return f"It is sunny in {city}." + + weather_tool = tool_as_activity( + get_weather, start_to_close_timeout=timedelta(seconds=30) + ) + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + tools=[weather_tool], + system_prompt="Use the weather tool to answer.", + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": f"Weather in {city}?"}]} + ) + return result["messages"][-1].content + + +@pytest.mark.asyncio +async def test_deep_agent_runs_via_plugin(env) -> None: + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["The answer is 42."]), + ) + async with Worker( + env.client, + task_queue="da-native", + workflows=[DeepAgentWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + DeepAgentWorkflow.run, + "What is the meaning of life?", + id=f"da-native-{uuid.uuid4()}", + task_queue="da-native", + ) + out = await handle.result() + + assert "42" in out + counts = await count_scheduled_activities(handle) + # The model call was made durable as an activity, with no user wiring. + assert counts[INVOKE_MODEL] >= 1, counts + + +@pytest.mark.asyncio +async def test_agent_tool_loop_routes_to_activities(env) -> None: + # Script the model: first turn asks for the tool, second turn answers. This + # forces model -> tool -> model, proving each call routes through an activity. + tool_call = AIMessage( + content="", + tool_calls=[{"name": "get_weather", "args": {"city": "Paris"}, "id": "call-1"}], + ) + final = AIMessage(content="It is sunny in Paris.") + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider([tool_call, final]), + ) + async with Worker( + env.client, + task_queue="da-tool-loop", + workflows=[ToolLoopWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + ToolLoopWorkflow.run, + "Paris", + id=f"da-tool-loop-{uuid.uuid4()}", + task_queue="da-tool-loop", + ) + out = await handle.result() + + assert "Paris" in out + counts = await count_scheduled_activities(handle) + assert counts[INVOKE_MODEL] == 2, counts + assert counts[INVOKE_TOOL] == 1, counts diff --git a/tests/contrib/deepagents/test_readme.py b/tests/contrib/deepagents/test_readme.py new file mode 100644 index 000000000..7aa8ddd7a --- /dev/null +++ b/tests/contrib/deepagents/test_readme.py @@ -0,0 +1,36 @@ +"""The README's code blocks must stay valid Python. + +A copy-paste example that does not even parse is worse than no example. This +compiles every ```python fenced block in the README so a stale snippet fails the +suite. Compilation (not execution) keeps the check dependency-free. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +README = ( + Path(__file__).resolve().parents[3] + / "temporalio" + / "contrib" + / "deepagents" + / "README.md" +) + +_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL) + + +def _python_blocks() -> list[str]: + return _BLOCK.findall(README.read_text(encoding="utf-8")) + + +def test_readme_has_python_blocks() -> None: + blocks = _python_blocks() + assert len(blocks) >= 3, "expected the hello-world and composition examples" + + +def test_readme_hello_world_constructs() -> None: + # Every documented snippet must compile as written. + for i, block in enumerate(_python_blocks()): + compile(block, f"", "exec") diff --git a/tests/contrib/deepagents/test_replay.py b/tests/contrib/deepagents/test_replay.py new file mode 100644 index 000000000..0e423489a --- /dev/null +++ b/tests/contrib/deepagents/test_replay.py @@ -0,0 +1,63 @@ +"""Recorded histories replay cleanly through the plugin. + +The Deep Agents control loop runs in the workflow, so replay determinism is the +core safety property. This records the history of a real run (a plain fake agent +driven by ``run_deep_agent``, no LangChain needed) and feeds it back through a +``Replayer`` configured with the plugin. A nondeterministic seam would raise on +replay; a clean pass proves the in-workflow dispatch is deterministic. +""" + +from __future__ import annotations + +import sys +import uuid +from typing import Any + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) +from temporalio import workflow +from temporalio.contrib.deepagents import DeepAgentsPlugin, run_deep_agent +from temporalio.worker import Replayer, Worker + + +class FakeAgent: + async def ainvoke(self, input: Any) -> dict: + messages = list(input.get("messages", [])) if isinstance(input, dict) else [] + return {"messages": [*messages, "answered"], "todos": []} + + +@workflow.defn +class ReplayWorkflow: + @workflow.run + async def run(self, input: dict) -> dict: + return await run_deep_agent(FakeAgent(), input) + + +@pytest.mark.asyncio +async def test_replay_with_plugin(env) -> None: + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-replay", + workflows=[ReplayWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + ReplayWorkflow.run, + {"messages": ["question"]}, + id=f"da-replay-{uuid.uuid4()}", + task_queue="da-replay", + ) + await handle.result() + history = await handle.fetch_history() + + # A fresh replayer (new worker identity) must replay the recorded history + # without a nondeterminism error. + replayer = Replayer( + workflows=[ReplayWorkflow], + plugins=[DeepAgentsPlugin()], + ) + await replayer.replay_workflow(history) diff --git a/tests/contrib/deepagents/test_side_effects.py b/tests/contrib/deepagents/test_side_effects.py new file mode 100644 index 000000000..6dcb31968 --- /dev/null +++ b/tests/contrib/deepagents/test_side_effects.py @@ -0,0 +1,72 @@ +"""Determinism: no unexpected side effects and a bounded activity count. + +Running the worker with ``max_cached_workflows=0`` forces the workflow to be +replayed from history on every activation. If the in-workflow dispatch did +anything nondeterministic, replay would diverge and the workflow would fail. A +clean completion plus an exact ``backend_op`` schedule count proves the seam +schedules one activity per op and nothing more. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import timedelta + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) +from temporalio import workflow +from temporalio.contrib.deepagents import DeepAgentsPlugin, TemporalBackend +from temporalio.worker import Worker +from tests.contrib.deepagents.helpers import count_scheduled_activities + +BACKEND_OP = "deepagents.backend_op" + + +class TwoOpBackend: + """Protocol-named ops: one async (``awrite``, as the filesystem + middleware calls it) and one sync (``read``).""" + + async def awrite(self, file_path: str, content: str) -> str: + return f"wrote:{file_path}" + + def read(self, file_path: str) -> str: + return f"read:{file_path}" + + +@workflow.defn +class TwoOpWorkflow: + @workflow.run + async def run(self) -> str: + backend = TemporalBackend( + TwoOpBackend(), + activity_options={"start_to_close_timeout": timedelta(seconds=10)}, + ) + await backend.awrite("a.txt", "hello") + return await backend.read("a.txt") + + +@pytest.mark.asyncio +async def test_activity_schedule_counts(env) -> None: + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-side-effects", + workflows=[TwoOpWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await env.client.start_workflow( + TwoOpWorkflow.run, + id=f"da-side-effects-{uuid.uuid4()}", + task_queue="da-side-effects", + ) + out = await handle.result() + + assert out == "read:a.txt" + counts = await count_scheduled_activities(handle) + # Exactly the two backend ops, each scheduled once — no hidden replays of work. + assert counts[BACKEND_OP] == 2, counts diff --git a/tests/contrib/deepagents/test_streaming.py b/tests/contrib/deepagents/test_streaming.py new file mode 100644 index 000000000..cb2685bb0 --- /dev/null +++ b/tests/contrib/deepagents/test_streaming.py @@ -0,0 +1,105 @@ +"""Streaming: model calls route through the streaming activity when a topic is set. + +Setting ``streaming_topic`` flips model dispatch from ``invoke_model`` to +``invoke_model_streaming``, which streams chunks out of the activity and returns +the aggregated final message to the workflow (so the durable result matches the +non-streaming path). These assert the observable effects: which activity is +scheduled, the aggregated content, and that a custom batch interval is threaded +into the streaming activity. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import timedelta + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) + +pytest.importorskip("langchain_core") + +from temporalio import workflow # noqa: E402 +from temporalio.worker import Worker # noqa: E402 +from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 + +with workflow.unsafe.imports_passed_through(): + from langchain_core.messages import HumanMessage + + from temporalio.contrib.deepagents import DeepAgentsPlugin, TemporalModel + from temporalio.contrib.deepagents.testing import mock_model_provider + +INVOKE_MODEL = "deepagents.invoke_model" +INVOKE_MODEL_STREAMING = "deepagents.invoke_model_streaming" + + +@workflow.defn +class StreamWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + model = TemporalModel(model="fake:model") + parts: list[str] = [] + async for chunk in model.astream([HumanMessage(content=prompt)]): + parts.append(str(chunk.content)) + return "".join(parts) + + +@pytest.mark.asyncio +async def test_stream_chunks_published(env) -> None: + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["Streamed answer."]), + streaming_topic="da-stream-topic", + model_activity_options={"start_to_close_timeout": timedelta(seconds=30)}, + ) + async with Worker( + env.client, + task_queue="da-stream", + workflows=[StreamWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + StreamWorkflow.run, + "stream please", + id=f"da-stream-{uuid.uuid4()}", + task_queue="da-stream", + ) + out = await handle.result() + + assert "Streamed answer." in out + counts = await count_scheduled_activities(handle) + # The topic is set, so dispatch used the streaming activity, not invoke_model. + assert counts[INVOKE_MODEL_STREAMING] == 1, counts + assert counts[INVOKE_MODEL] == 0, counts + + +@pytest.mark.asyncio +async def test_batch_interval_coalesces(env) -> None: + # A custom batch interval is threaded into the streaming activity that + # coalesces chunks; streaming still returns the aggregated message. + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["Batched."]), + streaming_topic="da-batch-topic", + streaming_batch_interval=timedelta(milliseconds=500), + ) + assert plugin._activities._streaming_batch_interval == timedelta(milliseconds=500) + + async with Worker( + env.client, + task_queue="da-batch", + workflows=[StreamWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + StreamWorkflow.run, + "batch please", + id=f"da-batch-{uuid.uuid4()}", + task_queue="da-batch", + ) + out = await handle.result() + + assert "Batched." in out + counts = await count_scheduled_activities(handle) + assert counts[INVOKE_MODEL_STREAMING] == 1, counts diff --git a/tests/contrib/deepagents/test_subagents.py b/tests/contrib/deepagents/test_subagents.py new file mode 100644 index 000000000..09fe687d2 --- /dev/null +++ b/tests/contrib/deepagents/test_subagents.py @@ -0,0 +1,84 @@ +"""Sub-agents inherit the activity seams. + +Deep Agents builds sub-agents as separate graphs, but they inherit the parent's +``model`` object and tools by default. Because the plugin substitutes the model +*object*, every sub-agent's model call routes through an activity without any +per-sub-agent wiring. This runs a real agent configured with a sub-agent and +asserts model calls still land on the activity seam. + +The exact number of hops depends on ``deepagents`` internals we do not pin, so +the assertion is the robust invariant: the configured agent runs and at least one +model call went through an activity. +""" + +from __future__ import annotations + +import sys +import uuid + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) + +pytest.importorskip("deepagents") +pytest.importorskip("langchain_core") + +from temporalio import workflow # noqa: E402 +from temporalio.worker import Worker # noqa: E402 +from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 + +with workflow.unsafe.imports_passed_through(): + from deepagents import create_deep_agent + + from temporalio.contrib.deepagents import DeepAgentsPlugin + from temporalio.contrib.deepagents.testing import mock_model_provider + +INVOKE_MODEL = "deepagents.invoke_model" + + +@workflow.defn +class SubAgentWorkflow: + @workflow.run + async def run(self, question: str) -> str: + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + system_prompt="You coordinate research.", + subagents=[ + { + "name": "researcher", + "description": "Researches a topic in depth.", + "system_prompt": "You research topics.", + } + ], + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": question}]} + ) + return result["messages"][-1].content + + +@pytest.mark.asyncio +async def test_subagent_calls_route_to_activities(env) -> None: + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider(["Coordinated answer."]), + ) + async with Worker( + env.client, + task_queue="da-subagent", + workflows=[SubAgentWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + SubAgentWorkflow.run, + "Investigate the topic.", + id=f"da-subagent-{uuid.uuid4()}", + task_queue="da-subagent", + ) + out = await handle.result() + + assert out + counts = await count_scheduled_activities(handle) + # A model instance shared with the sub-agent means model calls are activities. + assert counts[INVOKE_MODEL] >= 1, counts diff --git a/tests/contrib/deepagents/test_tools.py b/tests/contrib/deepagents/test_tools.py new file mode 100644 index 000000000..bf34bffb4 --- /dev/null +++ b/tests/contrib/deepagents/test_tools.py @@ -0,0 +1,120 @@ +"""The tool seam: existing activities and wrapped tools route to activities. + +These need LangChain (a tool is a ``BaseTool``) and guard on its import. Each +runs a real workflow that invokes the wrapped tool and asserts it scheduled the +expected activity. +""" + +from __future__ import annotations + +import sys +import uuid +from datetime import timedelta +from types import SimpleNamespace + +import pytest + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" +) + +pytest.importorskip("langchain_core") + +from temporalio import activity, workflow # noqa: E402 +from temporalio.worker import Worker # noqa: E402 +from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 + +with workflow.unsafe.imports_passed_through(): + from temporalio.contrib.deepagents import ( # noqa: E402 + DeepAgentsPlugin, + activity_as_tool, + tool_as_activity, + ) + from temporalio.contrib.deepagents._tools import warn_unwrapped_tools # noqa: E402 + +INVOKE_TOOL = "deepagents.invoke_tool" + + +@activity.defn +async def echo_activity(text: str) -> str: + return f"echo:{text}" + + +@workflow.defn +class ActivityAsToolWorkflow: + @workflow.run + async def run(self, text: str) -> str: + tool = activity_as_tool( + echo_activity, start_to_close_timeout=timedelta(seconds=10) + ) + return await tool.ainvoke({"text": text}) + + +@workflow.defn +class ToolAsActivityWorkflow: + @workflow.run + async def run(self, city: str) -> str: + def get_weather(city: str) -> str: + """Look up the weather for a city.""" + return f"sunny in {city}" + + tool = tool_as_activity( + get_weather, start_to_close_timeout=timedelta(seconds=10) + ) + message = await tool.ainvoke({"city": city}) + return message.content + + +@pytest.mark.asyncio +async def test_activity_as_tool(env) -> None: + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-act-tool", + workflows=[ActivityAsToolWorkflow], + activities=[echo_activity], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + ActivityAsToolWorkflow.run, + "hi", + id=f"da-act-tool-{uuid.uuid4()}", + task_queue="da-act-tool", + ) + out = await handle.result() + + assert out == "echo:hi" + counts = await count_scheduled_activities(handle) + assert counts["echo_activity"] == 1, counts + + +@pytest.mark.asyncio +async def test_tool_as_activity(env) -> None: + plugin = DeepAgentsPlugin() + async with Worker( + env.client, + task_queue="da-tool-act", + workflows=[ToolAsActivityWorkflow], + plugins=[plugin], + ): + handle = await env.client.start_workflow( + ToolAsActivityWorkflow.run, + "Paris", + id=f"da-tool-act-{uuid.uuid4()}", + task_queue="da-tool-act", + ) + out = await handle.result() + + assert "sunny in Paris" in out + counts = await count_scheduled_activities(handle) + assert counts[INVOKE_TOOL] == 1, counts + + +def test_builtin_tool_in_workflow(recwarn) -> None: + # Built-in tool names never warn (they are pure, in-workflow); an unwrapped + # user tool does warn so the Workflow-vs-Activity choice is conscious. + warn_unwrapped_tools([SimpleNamespace(name="write_todos")]) + assert len(recwarn) == 0 + + warn_unwrapped_tools([SimpleNamespace(name="scrape_website")]) + assert any("scrape_website" in str(w.message) for w in recwarn) diff --git a/uv.lock b/uv.lock index 0543cf0ed..37b97aa3b 100644 --- a/uv.lock +++ b/uv.lock @@ -257,6 +257,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.113.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/2a/f856135e5b055bf8b6f34133b313810dfe1cb848e5ac5ea843e196daefdd/anthropic-0.113.0.tar.gz", hash = "sha256:1830e866430ebd351c4f277d20e4c9b0aa9ad71f6569a23772ae88b33e0abaf8", size = 939444, upload-time = "2026-06-29T14:57:22.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/4d/ea001c0993b8f48153c5eb74eecf5a3ed120df07fe894757351e8308bbe6/anthropic-0.113.0-py3-none-any.whl", hash = "sha256:9a52ed7a4982e916fa878d1ed3eaec2dcdf98699a39d8fc45b54b3c50f1e7426", size = 937995, upload-time = "2026-06-29T14:57:23.721Z" }, +] + [[package]] name = "antlr4-python3-runtime" version = "4.13.2" @@ -928,6 +947,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] +[[package]] +name = "deepagents" +version = "0.6.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain" }, + { name = "langchain-anthropic" }, + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-google-genai" }, + { name = "langsmith" }, + { 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 = "dependency-groups" version = "1.3.1" @@ -996,7 +1032,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 = [ @@ -1100,6 +1136,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] +[[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" @@ -1936,10 +1981,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "langchain" +version = "1.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a2/91a7197c604a3ce1b774b3c10dd114c3c745c6186a304fc2573b3f94d400/langchain-1.3.11.tar.gz", hash = "sha256:f3cf9cd4d2329b1a03eb8fd92b9d73e4e58a4d52570d67725fc77fbe0f104b32", size = 633374, upload-time = "2026-06-22T23:00:33.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a4/3a181967294f8876362cc4ba36840d50b8286fa23bb3f5e602b69eb3cb1e/langchain-1.3.11-py3-none-any.whl", hash = "sha256:7ae011f95a09b22feea1e8ae4e43f0b6164aebf4c61b8ad845b45f72ff3a90a2", size = 133639, upload-time = "2026-06-22T23:00:31.619Z" }, +] + +[[package]] +name = "langchain-anthropic" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anthropic" }, + { name = "langchain-core", version = "1.4.8", 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.7" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ { name = "jsonpatch" }, { name = "langchain-protocol" }, @@ -1956,6 +2032,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3e/dcdffa60078ae7b3a00ebb4cbbf1a204a14c3609983c604886523a7d4418/langchain_core-1.4.7-py3-none-any.whl", hash = "sha256:bcadd51951140ecdcba98311dbd931ba5de02a5ba8a2288dad5069c1eea2a13d", size = 554941, upload-time = "2026-06-12T19:23:55.826Z" }, ] +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-google-genai" +version = "4.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filetype" }, + { name = "google-genai" }, + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/08/68e4f90b273d1d8dc3a7a948e6535e60baac726adbb2a8341dcf17662f54/langchain_google_genai-4.2.6.tar.gz", hash = "sha256:653dc331e691ddd79784d9ff6a4082749e0f873394c6dc782c414eb4409850eb", size = 280074, upload-time = "2026-06-26T06:18:58.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/3c/9a6b43bced0238f95640555e14423fe4a8268e3f4f536f67e26e79e633d4/langchain_google_genai-4.2.6-py3-none-any.whl", hash = "sha256:c40db0c2d033a5fb6db8e2cc3fb6d49c5678b89b337a64da095fb73ec9f72021", size = 70457, upload-time = "2026-06-26T06:18:57.781Z" }, +] + [[package]] name = "langchain-protocol" version = "0.0.17" @@ -1973,7 +2089,8 @@ name = "langgraph" version = "1.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "langgraph-checkpoint" }, { name = "langgraph-prebuilt" }, { name = "langgraph-sdk" }, @@ -1990,7 +2107,8 @@ name = "langgraph-checkpoint" version = "4.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "ormsgpack" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } @@ -2003,7 +2121,8 @@ name = "langgraph-prebuilt" version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.8", 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" } @@ -2017,7 +2136,8 @@ version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, - { name = "langchain-core" }, + { name = "langchain-core", version = "1.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "langchain-protocol" }, { name = "orjson" }, { name = "websockets" }, @@ -2765,7 +2885,7 @@ wheels = [ [package.optional-dependencies] litellm = [ - { name = "litellm", marker = "python_full_version < '3.14'" }, + { name = "litellm" }, ] [[package]] @@ -4679,6 +4799,11 @@ aioboto3 = [ { name = "aioboto3" }, { name = "types-aioboto3", extra = ["s3"] }, ] +deepagents = [ + { name = "deepagents", marker = "python_full_version >= '3.11'" }, + { name = "langchain", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] google-adk = [ { name = "google-adk" }, ] @@ -4721,9 +4846,13 @@ dev = [ { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "basedpyright" }, { name = "cibuildwheel" }, + { name = "deepagents", marker = "python_full_version >= '3.11'" }, { name = "googleapis-common-protos" }, { name = "grpcio-tools" }, { name = "httpx" }, + { name = "langchain", marker = "python_full_version >= '3.11'" }, + { name = "langchain-anthropic", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", version = "1.4.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "langgraph" }, { name = "langsmith" }, { name = "litellm" }, @@ -4762,9 +4891,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "aioboto3", marker = "extra == 'aioboto3'", specifier = ">=10.4.0" }, + { name = "deepagents", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=0.6.12,<0.7" }, { name = "google-adk", marker = "extra == 'google-adk'", specifier = ">=2.2.0,<3" }, { name = "google-genai", marker = "extra == 'google-genai'", specifier = ">=2.10.0,<3.0.0" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = ">=1.48.2,<2" }, + { name = "langchain", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=1.3.11,<2" }, + { name = "langchain-core", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=1.4.8,<2" }, { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.1.0" }, { name = "langsmith", marker = "extra == 'langsmith'", specifier = ">=0.7.34,<0.9" }, { name = "mcp", marker = "extra == 'openai-agents'", specifier = ">=1.9.4,<2" }, @@ -4785,16 +4917,20 @@ requires-dist = [ { name = "types-protobuf", specifier = ">=3.20,<8.0.0" }, { name = "typing-extensions", specifier = ">=4.2.0,<5" }, ] -provides-extras = ["grpc", "opentelemetry", "pydantic", "openai-agents", "google-adk", "langgraph", "langsmith", "lambda-worker-otel", "aioboto3", "google-genai", "strands-agents"] +provides-extras = ["grpc", "opentelemetry", "pydantic", "openai-agents", "google-adk", "langgraph", "langsmith", "deepagents", "lambda-worker-otel", "aioboto3", "google-genai", "strands-agents"] [package.metadata.requires-dev] dev = [ { name = "async-timeout", marker = "python_full_version < '3.11'", specifier = ">=4.0,<6" }, { name = "basedpyright", specifier = "==1.34.0" }, { name = "cibuildwheel", specifier = ">=2.22.0,<3" }, + { name = "deepagents", marker = "python_full_version >= '3.11'", specifier = ">=0.6.12,<0.7" }, { name = "googleapis-common-protos", specifier = ">=1.75.0,<2" }, { name = "grpcio-tools", specifier = ">=1.48.2,<2" }, { name = "httpx", specifier = ">=0.28.1" }, + { 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 = "langgraph", specifier = ">=1.1.0" }, { name = "langsmith", specifier = ">=0.7.34,<0.9" }, { name = "litellm", specifier = ">=1.83.0" }, @@ -5337,6 +5473,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 = "10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/98/eb989c3113908e2ef46d940a53695a1ebb4be5a732c4a4f700be8f8d682b/wcmatch-10.2.tar.gz", hash = "sha256:92204839e3e9c945e1e71d7e1e4edeab2601ed50a5c51ff4f3f97ca711eeb738", size = 132499, upload-time = "2026-06-30T00:50:07.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/73/aef4aaf16b8d785762e2b14cf321a9178cc84a1bf4c40f58320f499a2d65/wcmatch-10.2-py3-none-any.whl", hash = "sha256:f1a79e80ccbe296907b7eaf57d8d3bc49eab0b428d35f7d09986b5079b6e4a5d", size = 39742, upload-time = "2026-06-30T00:50:05.927Z" }, +] + [[package]] name = "wcwidth" version = "0.8.1" From 96e2323182571db94e70cad21703742cace2558a Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 13:23:45 -0500 Subject: [PATCH 2/7] Run backend protocol async defaults inline in workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deepagents' BackendProtocol implements every async method's DEFAULT as asyncio.to_thread(sync_twin, ...), and neither StateBackend nor FilesystemBackend overrides any of them. The deterministic workflow event loop has no thread executor, so an agent's first built-in tool call against an unwrapped in-workflow backend — e.g. a model spontaneously invoking grep on the default state backend — failed the workflow task with NotImplementedError. Scripted-model tests never invoke built-ins on the default backend, so only a live-model run surfaced it. The plugin now patches the protocol's async defaults at worker start (same seam pattern as the resolve_model patch): inside a workflow the sync twin runs inline, which for state-only backends is deterministic and semantically identical to the upstream default; outside a workflow the upstream thread-hop default is untouched, as are subclasses that override an async method natively. --- temporalio/contrib/deepagents/_plugin.py | 6 +- temporalio/contrib/deepagents/_tools.py | 68 +++++++++++++++++++++++ tests/contrib/deepagents/test_backends.py | 59 ++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/temporalio/contrib/deepagents/_plugin.py b/temporalio/contrib/deepagents/_plugin.py index 85e66f9b7..329ec2ae5 100644 --- a/temporalio/contrib/deepagents/_plugin.py +++ b/temporalio/contrib/deepagents/_plugin.py @@ -180,9 +180,10 @@ async def _run_context(self) -> AsyncIterator[None]: # worker must still start — it simply runs without the auto-wrap patch. patched = False try: - from temporalio.contrib.deepagents import _model + from temporalio.contrib.deepagents import _model, _tools _model.install_model_patch() + _tools.install_backend_async_patch() patched = True except Exception: # LangChain / deepagents not importable on this worker warnings.warn( @@ -197,9 +198,10 @@ async def _run_context(self) -> AsyncIterator[None]: finally: if patched: # Import is cached: patched=True implies the import above succeeded. - from temporalio.contrib.deepagents import _model + from temporalio.contrib.deepagents import _model, _tools _model.uninstall_model_patch() + _tools.uninstall_backend_async_patch() if langsmith_patched: _uninstall_langsmith_temporal_override() diff --git a/temporalio/contrib/deepagents/_tools.py b/temporalio/contrib/deepagents/_tools.py index 00815a7be..28702f0c4 100644 --- a/temporalio/contrib/deepagents/_tools.py +++ b/temporalio/contrib/deepagents/_tools.py @@ -303,6 +303,74 @@ def _is_coroutine(fn: Any) -> bool: # TemporalBackend # --------------------------------------------------------------------------- +# Async protocol methods and their sync twins. deepagents' ``BackendProtocol`` +# implements each async DEFAULT as ``asyncio.to_thread(sync_twin, ...)``; the +# deterministic workflow event loop has no thread executor, so any built-in +# tool call against an unwrapped in-workflow backend (e.g. the default +# ``StateBackend``) raises ``NotImplementedError``. A real model hits this on +# its first spontaneous ``grep``/``read_file`` call; scripted-model tests that +# never call built-ins sail past it. +_ASYNC_TO_SYNC_OPS: dict[str, str] = { + "als": "ls", + "als_info": "ls_info", + "aread": "read", + "awrite": "write", + "aedit": "edit", + "aglob": "glob", + "aglob_info": "glob_info", + "agrep": "grep", + "agrep_raw": "grep_raw", + "adownload_files": "download_files", + "aupload_files": "upload_files", + "aexecute": "execute", +} + +_original_backend_async_defaults: dict[str, Any] = {} + + +def install_backend_async_patch() -> None: + """Make ``BackendProtocol``'s async defaults workflow-safe. + + Inside a workflow, run the sync twin inline: for state-only backends that + is deterministic and semantically identical to the upstream default, + which merely moves the same sync call onto a worker thread. Outside a + workflow (activities, clients) the upstream default — thread hop plus + timeout guard — is used unchanged. Subclasses that override an async + method natively are unaffected; only the protocol defaults are replaced. + Idempotent. + """ + from deepagents.backends.protocol import BackendProtocol + + if _original_backend_async_defaults: + return + for async_name, sync_name in _ASYNC_TO_SYNC_OPS.items(): + original = BackendProtocol.__dict__.get(async_name) + if original is None: + continue + + def _make(sync_name: str, original: Any) -> Any: + async def patched(self: Any, *args: Any, **kwargs: Any) -> Any: + if workflow.in_workflow(): + return getattr(self, sync_name)(*args, **kwargs) + return await original(self, *args, **kwargs) + + return patched + + _original_backend_async_defaults[async_name] = original + setattr(BackendProtocol, async_name, _make(sync_name, original)) + + +def uninstall_backend_async_patch() -> None: + """Restore ``BackendProtocol``'s upstream async defaults.""" + if not _original_backend_async_defaults: + return + from deepagents.backends.protocol import BackendProtocol + + for async_name, original in _original_backend_async_defaults.items(): + setattr(BackendProtocol, async_name, original) + _original_backend_async_defaults.clear() + + # Backend METHOD names (deepagents ``BackendProtocol`` + # ``SandboxBackendProtocol``) whose calls must cross the activity boundary: # every sync I/O method, its async ``a``-prefixed twin, and the sandbox/shell diff --git a/tests/contrib/deepagents/test_backends.py b/tests/contrib/deepagents/test_backends.py index 18fba4969..a43eaead5 100644 --- a/tests/contrib/deepagents/test_backends.py +++ b/tests/contrib/deepagents/test_backends.py @@ -210,3 +210,62 @@ async def test_agent_builtin_file_tools_route_backend_ops(env, tmp_path) -> None # Exactly one backend_op per file tool call (awrite + aread), three model turns. assert counts[BACKEND_OP] == 2, counts assert counts["deepagents.invoke_model"] == 3, counts + + +@workflow.defn +class DefaultBackendGrepWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + # No backend argument: deepagents uses its default state-only backend. + agent = create_deep_agent(model="anthropic:claude-sonnet-4-5") + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": prompt}]} + ) + return str(result["messages"][-1].content) + + +@pytest.mark.asyncio +async def test_builtin_tool_on_default_backend_runs_in_workflow(env) -> None: + """A built-in tool call (grep) on the DEFAULT state backend runs inline + in the workflow — no activity, no thread hop — under + ``max_cached_workflows=0`` so every task replays from history. + + Regression: ``BackendProtocol``'s async defaults wrap their sync twins in + ``asyncio.to_thread``, which the deterministic workflow event loop + rejects with ``NotImplementedError``. A real model's first spontaneous + ``grep``/``read_file`` call crashed the workflow task; scripted tests + that never invoked built-ins on the default backend sailed past it. The + plugin now runs the sync twin inline when ``workflow.in_workflow()``. + """ + from langchain_core.messages import AIMessage + + from temporalio.contrib.deepagents.testing import mock_model_provider + + grep_turn = AIMessage( + content="", + tool_calls=[{"name": "grep", "args": {"pattern": "hello"}, "id": "call-grep"}], + ) + final = AIMessage(content="No matches found; done.") + plugin = DeepAgentsPlugin( + model_provider=mock_model_provider([grep_turn, final]), + ) + async with Worker( + env.client, + task_queue="da-default-backend", + workflows=[DefaultBackendGrepWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await env.client.start_workflow( + DefaultBackendGrepWorkflow.run, + "Grep the workspace for 'hello'.", + id=f"da-default-backend-{uuid.uuid4()}", + task_queue="da-default-backend", + ) + out = await handle.result() + + assert "done" in out + counts = await count_scheduled_activities(handle) + # The state-backend op stays in-workflow: model turns are the ONLY activities. + assert counts[BACKEND_OP] == 0, counts + assert counts["deepagents.invoke_model"] == 2, counts From cdd8ad6c017932108aebcf2abf151ac68b12774f Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 13:36:25 -0500 Subject: [PATCH 3/7] Return tool content so the node stamps the model's tool_call_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_as_activity returned the ToolMessage assembled inside the invoke_tool activity, whose tool_call_id is workflow-generated — the activity cannot know the id the model minted for the call. A real provider (Anthropic) rejects the next model turn with 400 "unexpected tool_use_id found in tool_result blocks" because the tool_result does not pair with any tool_use in the previous message. Offline fakes never validate the pairing, so only a live-model run surfaced it. The wrapper now returns the tool result CONTENT and lets the tool node stamp the model's own tool_call_id — the same path unwrapped tools take. activity_as_tool already returned raw content and is unaffected. The regression test records the fake model's second-turn request and asserts the tool_result rides under the scripted id. --- temporalio/contrib/deepagents/_tools.py | 13 ++- tests/contrib/deepagents/test_tools.py | 107 +++++++++++++++++++++++- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/temporalio/contrib/deepagents/_tools.py b/temporalio/contrib/deepagents/_tools.py index 28702f0c4..8e89103ad 100644 --- a/temporalio/contrib/deepagents/_tools.py +++ b/temporalio/contrib/deepagents/_tools.py @@ -282,7 +282,18 @@ async def _run(**kwargs: Any) -> Any: summary=f"tool:{tool_name}", **opts, ) - return _serde.load_object(output.message) + message = _serde.load_object(output.message) + # Return CONTENT, not the pre-built ToolMessage: the activity cannot + # know the model's real tool_call_id, so a ToolMessage assembled there + # carries a generated id that a real provider rejects as an unpaired + # tool_result. Given plain content, the tool node stamps the model's + # own id — exactly as it does for unwrapped tools. + with workflow.unsafe.imports_passed_through(): + from langchain_core.messages import ToolMessage + + if isinstance(message, ToolMessage): + return message.content + return message _ROUTED_TOOL_NAMES.add(tool_name) return StructuredTool( diff --git a/tests/contrib/deepagents/test_tools.py b/tests/contrib/deepagents/test_tools.py index bf34bffb4..5836a654f 100644 --- a/tests/contrib/deepagents/test_tools.py +++ b/tests/contrib/deepagents/test_tools.py @@ -11,6 +11,7 @@ import uuid from datetime import timedelta from types import SimpleNamespace +from typing import Any, Sequence import pytest @@ -35,6 +36,11 @@ INVOKE_TOOL = "deepagents.invoke_tool" +def pairing_weather(city: str) -> str: + """Return the weather for a city.""" + return f"weather:{city}" + + @activity.defn async def echo_activity(text: str) -> str: return f"echo:{text}" @@ -61,8 +67,9 @@ def get_weather(city: str) -> str: tool = tool_as_activity( get_weather, start_to_close_timeout=timedelta(seconds=10) ) - message = await tool.ainvoke({"city": city}) - return message.content + # The wrapper returns plain CONTENT (the tool node stamps the model's + # tool_call_id onto it) — not a pre-built ToolMessage. + return str(await tool.ainvoke({"city": city})) @pytest.mark.asyncio @@ -118,3 +125,99 @@ def test_builtin_tool_in_workflow(recwarn) -> None: warn_unwrapped_tools([SimpleNamespace(name="scrape_website")]) assert any("scrape_website" in str(w.message) for w in recwarn) + + +# Turn-2 requests observed by the fake model, captured activity-side. Module +# state is shared with the in-process worker, same as the tool registries. +_captured_requests: list[list] = [] + + +@workflow.defn +class ToolCallIdPairingWorkflow: + @workflow.run + async def run(self, city: str) -> str: + from deepagents import create_deep_agent + + weather_tool = tool_as_activity( + pairing_weather, start_to_close_timeout=timedelta(seconds=10) + ) + agent = create_deep_agent( + model="anthropic:claude-sonnet-4-5", + tools=[weather_tool], + system_prompt="Use the weather tool.", + ) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": f"Weather in {city}?"}]} + ) + return str(result["messages"][-1].content) + + +@pytest.mark.asyncio +async def test_wrapped_tool_result_pairs_with_model_tool_call_id(env) -> None: + """The tool_result the model sees on turn 2 must carry the model's OWN + tool_call_id. Regression: ``tool_as_activity`` returned the activity-built + ``ToolMessage`` whose workflow-generated id a real provider (Anthropic) + rejects as an unpaired ``tool_result`` — offline fakes never validate the + pairing, so only a live-model run surfaced it. The wrapper now returns + plain content and the tool node stamps the correct id. + """ + from langchain_core.messages import AIMessage, ToolMessage + + from temporalio.contrib.deepagents.testing import FakeModel + + _captured_requests.clear() + + class RecordingModel(FakeModel): + def __init__(self, responses: Sequence[Any]) -> None: + super().__init__(responses) + + async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): + _captured_requests.append(list(messages)) + return await super()._agenerate( + messages, stop=stop, run_manager=run_manager, **kwargs + ) + + tool_turn = AIMessage( + content="", + tool_calls=[ + { + "name": "pairing_weather", + "args": {"city": "Paris"}, + "id": "toolu_scripted_pairing_id", + } + ], + ) + final = AIMessage(content="It is sunny in Paris.") + responses = [tool_turn, final] + cursor = {"i": 0} + + def provider(_model_name: str) -> RecordingModel: + reply = responses[cursor["i"] % len(responses)] + cursor["i"] += 1 + return RecordingModel([reply]) + + plugin = DeepAgentsPlugin(model_provider=provider) + async with Worker( + env.client, + task_queue="da-toolcall-pairing", + workflows=[ToolCallIdPairingWorkflow], + plugins=[plugin], + max_cached_workflows=0, + ): + handle = await env.client.start_workflow( + ToolCallIdPairingWorkflow.run, + "Paris", + id=f"da-toolcall-pairing-{uuid.uuid4()}", + task_queue="da-toolcall-pairing", + ) + out = await handle.result() + + assert "sunny" in out.lower() + # Turn 2's request must contain the tool result under the MODEL's id. + assert len(_captured_requests) >= 2, len(_captured_requests) + tool_messages = [m for m in _captured_requests[1] if isinstance(m, ToolMessage)] + assert tool_messages, _captured_requests[1] + assert tool_messages[0].tool_call_id == "toolu_scripted_pairing_id", tool_messages[ + 0 + ].tool_call_id + assert "weather:Paris" in str(tool_messages[0].content) From 9182ed0d59f7885d8e436905833b62323c06dd2e Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 14:19:59 -0500 Subject: [PATCH 4/7] Satisfy lint on 3.10 and basedpyright's warning gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI-only failure classes the local scoped runs missed: - basedpyright fails on warnings repo-wide. Replace deprecated typing aliases (Mapping/Sequence/AsyncIterator/Iterator/List/Optional/Union) with collections.abc / PEP 604 forms, type the test fixtures the way the rest of the suite does, drop unused imports/params, and keep the interpreter-floor warning behind a module constant so newer runtimes do not narrow it into unreachable code. - Python 3.10 jobs cannot install deepagents (its floor is 3.11), so pyright cannot resolve those imports there. Runtime imports of deepagents/langchain in module code go through importlib (attribute access on ModuleType is dynamic; monkeypatch writes use setattr), and the deepagents test modules carry a file-level pyright directive alongside their existing importorskip guards. langchain-core stays statically imported — it resolves everywhere via the langgraph extra. --- temporalio/contrib/deepagents/_activity.py | 6 +++- temporalio/contrib/deepagents/_model.py | 24 ++++++++------- temporalio/contrib/deepagents/_plugin.py | 10 +++++-- temporalio/contrib/deepagents/_tools.py | 14 +++++++-- temporalio/contrib/deepagents/testing.py | 19 ++++++------ temporalio/contrib/deepagents/workflow.py | 3 +- tests/contrib/deepagents/test_backends.py | 30 ++++++++++++++----- tests/contrib/deepagents/test_checkpointer.py | 9 +++++- .../deepagents/test_continue_as_new.py | 4 ++- tests/contrib/deepagents/test_failures.py | 6 ++-- tests/contrib/deepagents/test_hitl.py | 9 +++++- .../contrib/deepagents/test_model_activity.py | 6 ++-- tests/contrib/deepagents/test_native_e2e.py | 13 +++++--- tests/contrib/deepagents/test_replay.py | 4 ++- tests/contrib/deepagents/test_side_effects.py | 6 ++-- tests/contrib/deepagents/test_streaming.py | 6 ++-- tests/contrib/deepagents/test_subagents.py | 9 +++++- tests/contrib/deepagents/test_tools.py | 28 +++++++++++++---- 18 files changed, 149 insertions(+), 57 deletions(-) diff --git a/temporalio/contrib/deepagents/_activity.py b/temporalio/contrib/deepagents/_activity.py index 3e33986ad..93892ab3f 100644 --- a/temporalio/contrib/deepagents/_activity.py +++ b/temporalio/contrib/deepagents/_activity.py @@ -25,6 +25,7 @@ import asyncio import dataclasses +import importlib from datetime import timedelta from functools import wraps from typing import Any, Callable @@ -209,7 +210,10 @@ def _default_model_provider(model_name: str) -> Any: logical attempt fans out into nested retry storms that Temporal can neither see nor bound. """ - from langchain.chat_models import init_chat_model + # importlib: `langchain` (unlike langchain-core) is absent on Python 3.10 + # environments where the deepagents extra cannot install; a static import + # here fails type-checking there. + init_chat_model = importlib.import_module("langchain.chat_models").init_chat_model return init_chat_model(model_name, max_retries=0) diff --git a/temporalio/contrib/deepagents/_model.py b/temporalio/contrib/deepagents/_model.py index fc68514c5..2d97f992d 100644 --- a/temporalio/contrib/deepagents/_model.py +++ b/temporalio/contrib/deepagents/_model.py @@ -26,8 +26,10 @@ from __future__ import annotations +import importlib +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence from datetime import timedelta -from typing import Any, AsyncIterator, Iterator, Mapping, Sequence +from typing import Any from temporalio import workflow from temporalio.contrib.deepagents import _activity, _serde @@ -323,18 +325,20 @@ def install_model_patch() -> None: deepagents on a plain client / activity worker is unaffected. Idempotent. """ global _original_create_deep_agent, _original_resolve_model - import deepagents - import deepagents.graph as _graph + # importlib: `deepagents` is absent on Python 3.10 environments (its floor + # is 3.11), so static imports here fail type-checking there. + deepagents = importlib.import_module("deepagents") + _graph = importlib.import_module("deepagents.graph") if _original_resolve_model is None: - _original_resolve_model = _graph.resolve_model # pyright: ignore[reportPrivateImportUsage] + _original_resolve_model = _graph.resolve_model def patched_resolve_model(model: Any) -> Any: if workflow.in_workflow(): return _wrap_model_arg(model) return _original_resolve_model(model) - _graph.resolve_model = patched_resolve_model # pyright: ignore[reportPrivateImportUsage] + setattr(_graph, "resolve_model", patched_resolve_model) if _original_create_deep_agent is None: _original_create_deep_agent = deepagents.create_deep_agent @@ -350,19 +354,19 @@ def patched(*args: Any, **kwargs: Any) -> Any: warn_durable_checkpointer(kwargs.get("checkpointer")) return _original_create_deep_agent(*args, **kwargs) - deepagents.create_deep_agent = patched + setattr(deepagents, "create_deep_agent", patched) def uninstall_model_patch() -> None: """Restore the original ``resolve_model`` / ``create_deep_agent``.""" global _original_create_deep_agent, _original_resolve_model if _original_resolve_model is not None: - import deepagents.graph as _graph + _graph = importlib.import_module("deepagents.graph") - _graph.resolve_model = _original_resolve_model # pyright: ignore[reportPrivateImportUsage] + setattr(_graph, "resolve_model", _original_resolve_model) _original_resolve_model = None if _original_create_deep_agent is not None: - import deepagents + deepagents = importlib.import_module("deepagents") - deepagents.create_deep_agent = _original_create_deep_agent + setattr(deepagents, "create_deep_agent", _original_create_deep_agent) _original_create_deep_agent = None diff --git a/temporalio/contrib/deepagents/_plugin.py b/temporalio/contrib/deepagents/_plugin.py index 329ec2ae5..e7b17ee4b 100644 --- a/temporalio/contrib/deepagents/_plugin.py +++ b/temporalio/contrib/deepagents/_plugin.py @@ -26,10 +26,11 @@ import sys import warnings +from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from dataclasses import replace from datetime import timedelta -from typing import Any, AsyncIterator, Callable, Mapping, Sequence +from typing import Any, Callable from temporalio import activity as activity_mod from temporalio.contrib.deepagents import _serde, _tools @@ -39,6 +40,11 @@ from temporalio.worker import WorkflowRunner from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner +# Runtime floor for the Deep Agents control loop. Kept in a constant so +# static checkers do not narrow `sys.version_info` comparisons into +# unreachable-code findings on new interpreters. +_MIN_PYTHON = (3, 11) + class DeepAgentsPlugin(SimplePlugin): """Temporal plugin that makes LangChain Deep Agents durable. @@ -75,7 +81,7 @@ def __init__( data_converter: Any = None, ) -> None: """Configure the plugin; see the class docstring for parameters.""" - if sys.version_info < (3, 11): + if sys.version_info < _MIN_PYTHON: warnings.warn( "DeepAgentsPlugin requires Python >= 3.11 (deepagents pins " ">=3.11); the Deep Agents control loop relies on contextvars " diff --git a/temporalio/contrib/deepagents/_tools.py b/temporalio/contrib/deepagents/_tools.py index 8e89103ad..c3956cfc8 100644 --- a/temporalio/contrib/deepagents/_tools.py +++ b/temporalio/contrib/deepagents/_tools.py @@ -26,11 +26,13 @@ from __future__ import annotations +import importlib import uuid as _uuid import warnings +from collections.abc import Mapping from datetime import timedelta from functools import wraps -from typing import TYPE_CHECKING, Any, Callable, Mapping +from typing import TYPE_CHECKING, Any, Callable from temporalio import activity as activity_mod from temporalio import workflow @@ -350,7 +352,11 @@ def install_backend_async_patch() -> None: method natively are unaffected; only the protocol defaults are replaced. Idempotent. """ - from deepagents.backends.protocol import BackendProtocol + # importlib: `deepagents` is absent on Python 3.10 environments (its floor + # is 3.11), so a static import here fails type-checking there. + BackendProtocol = importlib.import_module( + "deepagents.backends.protocol" + ).BackendProtocol if _original_backend_async_defaults: return @@ -375,7 +381,9 @@ def uninstall_backend_async_patch() -> None: """Restore ``BackendProtocol``'s upstream async defaults.""" if not _original_backend_async_defaults: return - from deepagents.backends.protocol import BackendProtocol + BackendProtocol = importlib.import_module( + "deepagents.backends.protocol" + ).BackendProtocol for async_name, original in _original_backend_async_defaults.items(): setattr(BackendProtocol, async_name, original) diff --git a/temporalio/contrib/deepagents/testing.py b/temporalio/contrib/deepagents/testing.py index 136e6a975..7d84166aa 100644 --- a/temporalio/contrib/deepagents/testing.py +++ b/temporalio/contrib/deepagents/testing.py @@ -15,7 +15,8 @@ from __future__ import annotations -from typing import Any, Callable, List, Optional, Sequence, Union +from collections.abc import Callable, Sequence +from typing import Any from langchain_core.callbacks import ( AsyncCallbackManagerForLLMRun, @@ -29,7 +30,7 @@ __all__ = ["FakeModel", "fake_model_factory", "mock_model_provider", "MockTool"] -Response = Union[str, AIMessage] +Response = str | AIMessage class FakeModel(BaseChatModel): @@ -41,7 +42,7 @@ class FakeModel(BaseChatModel): can script ``tool_calls`` to drive the agent's tool path). """ - responses: List[Any] + responses: list[Any] _cursor: int = PrivateAttr(default=0) def __init__(self, responses: Sequence[Response], **kwargs: Any) -> None: @@ -70,18 +71,18 @@ def _next(self) -> AIMessage: def _generate( self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[CallbackManagerForLLMRun] = None, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: CallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: return ChatResult(generations=[ChatGeneration(message=self._next())]) async def _agenerate( self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[AsyncCallbackManagerForLLMRun] = None, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = None, **kwargs: Any, ) -> ChatResult: return ChatResult(generations=[ChatGeneration(message=self._next())]) diff --git a/temporalio/contrib/deepagents/workflow.py b/temporalio/contrib/deepagents/workflow.py index b7798db63..26f542cb3 100644 --- a/temporalio/contrib/deepagents/workflow.py +++ b/temporalio/contrib/deepagents/workflow.py @@ -14,7 +14,8 @@ from __future__ import annotations import warnings -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from temporalio import workflow from temporalio.contrib.deepagents import _activity, _serde diff --git a/tests/contrib/deepagents/test_backends.py b/tests/contrib/deepagents/test_backends.py index a43eaead5..40dcca358 100644 --- a/tests/contrib/deepagents/test_backends.py +++ b/tests/contrib/deepagents/test_backends.py @@ -10,15 +10,23 @@ ``deepagents.StateBackend`` when it is importable. """ +# The deepagents / langchain optional deps cannot install on Python 3.10 +# (deepagents pins >=3.11), so pyright cannot resolve their imports there; +# runtime collection is guarded by importorskip below. +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false + from __future__ import annotations import sys import uuid from datetime import timedelta +from pathlib import Path from typing import cast import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -71,10 +79,12 @@ async def run(self, path: str) -> str: @workflow.defn class StateBackendWorkflow: @workflow.run - async def run(self) -> bool: + async def run(self) -> str: backend = StateBackend() - # Merely holding a StateBackend schedules no activity; it is not wrapped. - return backend is not None + # Merely holding a StateBackend schedules no activity; it is not + # wrapped. Return the class provenance so the assertion is on a real + # runtime property rather than a statically-decidable comparison. + return type(backend).__module__ # The full agent-level seam: a REAL Deep Agent whose BUILT-IN file tools drive a @@ -105,7 +115,7 @@ async def run(self, root_dir: str) -> str: @pytest.mark.asyncio -async def test_temporal_backend_op_activity(env) -> None: +async def test_temporal_backend_op_activity(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin() async with Worker( env.client, @@ -128,7 +138,7 @@ async def test_temporal_backend_op_activity(env) -> None: @pytest.mark.asyncio -async def test_state_backend_in_workflow(env) -> None: +async def test_state_backend_in_workflow(env: WorkflowEnvironment) -> None: # A state-only backend is pure workflow state and must NOT schedule an # activity. Exercised against the real StateBackend when deepagents is present. plugin = DeepAgentsPlugin() @@ -143,13 +153,15 @@ async def test_state_backend_in_workflow(env) -> None: id=f"da-state-backend-{uuid.uuid4()}", task_queue="da-state-backend", ) - assert await handle.result() is True + assert (await handle.result()).startswith("deepagents") counts = await count_scheduled_activities(handle) assert counts[BACKEND_OP] == 0, counts @pytest.mark.asyncio -async def test_agent_builtin_file_tools_route_backend_ops(env, tmp_path) -> None: +async def test_agent_builtin_file_tools_route_backend_ops( + env: WorkflowEnvironment, tmp_path: Path +) -> None: """An unmodified agent's built-in write_file/read_file tools cross the activity boundary when the backend is TemporalBackend-wrapped — under ``max_cached_workflows=0``, so every workflow task replays from history. @@ -225,7 +237,9 @@ async def run(self, prompt: str) -> str: @pytest.mark.asyncio -async def test_builtin_tool_on_default_backend_runs_in_workflow(env) -> None: +async def test_builtin_tool_on_default_backend_runs_in_workflow( + env: WorkflowEnvironment, +) -> None: """A built-in tool call (grep) on the DEFAULT state backend runs inline in the workflow — no activity, no thread hop — under ``max_cached_workflows=0`` so every task replays from history. diff --git a/tests/contrib/deepagents/test_checkpointer.py b/tests/contrib/deepagents/test_checkpointer.py index ddbd47a4f..c5969f6b7 100644 --- a/tests/contrib/deepagents/test_checkpointer.py +++ b/tests/contrib/deepagents/test_checkpointer.py @@ -7,6 +7,11 @@ and points at the snapshot + continue-as-new path. """ +# The deepagents / langchain optional deps cannot install on Python 3.10 +# (deepagents pins >=3.11), so pyright cannot resolve their imports there; +# runtime collection is guarded by importorskip below. +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false + from __future__ import annotations import sys @@ -15,6 +20,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -61,7 +68,7 @@ def test_durable_checkpointer_warns() -> None: @pytest.mark.asyncio -async def test_default_saver_rehydrates(env) -> None: +async def test_default_saver_rehydrates(env: WorkflowEnvironment) -> None: # Against the real deepagents default (in-workflow InMemorySaver): the agent # runs and its recorded history replays cleanly, proving replay rehydrates # the in-workflow checkpoint state with no external checkpointer. diff --git a/tests/contrib/deepagents/test_continue_as_new.py b/tests/contrib/deepagents/test_continue_as_new.py index 15a788b8d..0603f6eb8 100644 --- a/tests/contrib/deepagents/test_continue_as_new.py +++ b/tests/contrib/deepagents/test_continue_as_new.py @@ -16,6 +16,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -59,7 +61,7 @@ async def run(self, input: dict, state_snapshot: dict | None = None) -> dict: @pytest.mark.asyncio -async def test_can_threshold_and_cache(env) -> None: +async def test_can_threshold_and_cache(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin() async with Worker( env.client, diff --git a/tests/contrib/deepagents/test_failures.py b/tests/contrib/deepagents/test_failures.py index d3317a1ab..6cfe6e5ff 100644 --- a/tests/contrib/deepagents/test_failures.py +++ b/tests/contrib/deepagents/test_failures.py @@ -15,6 +15,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -78,7 +80,7 @@ async def run(self) -> None: @pytest.mark.asyncio -async def test_workflow_failure_type(env) -> None: +async def test_workflow_failure_type(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin() async with Worker( env.client, @@ -101,7 +103,7 @@ async def test_workflow_failure_type(env) -> None: @pytest.mark.asyncio -async def test_unwrappable_model_instance(env) -> None: +async def test_unwrappable_model_instance() -> None: pytest.importorskip("langchain_core") from langchain_core.language_models.fake_chat_models import FakeListChatModel diff --git a/tests/contrib/deepagents/test_hitl.py b/tests/contrib/deepagents/test_hitl.py index 9015837d3..befbe46aa 100644 --- a/tests/contrib/deepagents/test_hitl.py +++ b/tests/contrib/deepagents/test_hitl.py @@ -15,6 +15,11 @@ Temporal pattern for state shared between the run method and its handlers. """ +# The deepagents / langchain optional deps cannot install on Python 3.10 +# (deepagents pins >=3.11), so pyright cannot resolve their imports there; +# runtime collection is guarded by importorskip below. +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false + from __future__ import annotations import asyncio @@ -25,6 +30,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -96,7 +103,7 @@ async def resume(self, decision: str) -> None: @pytest.mark.asyncio -async def test_interrupt_query_then_resume(env) -> None: +async def test_interrupt_query_then_resume(env: WorkflowEnvironment) -> None: approve = AIMessage( content="", tool_calls=[{"name": "book_trip", "args": {"city": "Rome"}, "id": "c1"}], diff --git a/tests/contrib/deepagents/test_model_activity.py b/tests/contrib/deepagents/test_model_activity.py index 838763fcc..37a98ef00 100644 --- a/tests/contrib/deepagents/test_model_activity.py +++ b/tests/contrib/deepagents/test_model_activity.py @@ -12,6 +12,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -53,7 +55,7 @@ async def run(self, prompt: str) -> str: @pytest.mark.asyncio -async def test_model_call_is_activity(env) -> None: +async def test_model_call_is_activity(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin( model_provider=mock_model_provider(["The capital of France is Paris."]), model_activity_options={"start_to_close_timeout": timedelta(seconds=30)}, @@ -77,7 +79,7 @@ async def test_model_call_is_activity(env) -> None: @pytest.mark.asyncio -async def test_temporal_model_explicit(env) -> None: +async def test_temporal_model_explicit(env: WorkflowEnvironment) -> None: # The explicit escape hatch routes through the same activity, with a # per-model timeout override rather than the plugin default. plugin = DeepAgentsPlugin( diff --git a/tests/contrib/deepagents/test_native_e2e.py b/tests/contrib/deepagents/test_native_e2e.py index 8de4f78dc..ce0783c3c 100644 --- a/tests/contrib/deepagents/test_native_e2e.py +++ b/tests/contrib/deepagents/test_native_e2e.py @@ -11,6 +11,11 @@ docs-only checkout — there is no env-var gate. """ +# The deepagents / langchain optional deps cannot install on Python 3.10 +# (deepagents pins >=3.11), so pyright cannot resolve their imports there; +# runtime collection is guarded by importorskip below. +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false + from __future__ import annotations import sys @@ -19,6 +24,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -26,8 +33,6 @@ pytest.importorskip("deepagents") pytest.importorskip("langchain_core") -import deepagents # noqa: E402,F401 (cardinal rule: import the SDK directly) - from temporalio import workflow # noqa: E402 from temporalio.worker import Worker # noqa: E402 from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 @@ -80,7 +85,7 @@ def get_weather(city: str) -> str: @pytest.mark.asyncio -async def test_deep_agent_runs_via_plugin(env) -> None: +async def test_deep_agent_runs_via_plugin(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin( model_provider=mock_model_provider(["The answer is 42."]), ) @@ -105,7 +110,7 @@ async def test_deep_agent_runs_via_plugin(env) -> None: @pytest.mark.asyncio -async def test_agent_tool_loop_routes_to_activities(env) -> None: +async def test_agent_tool_loop_routes_to_activities(env: WorkflowEnvironment) -> None: # Script the model: first turn asks for the tool, second turn answers. This # forces model -> tool -> model, proving each call routes through an activity. tool_call = AIMessage( diff --git a/tests/contrib/deepagents/test_replay.py b/tests/contrib/deepagents/test_replay.py index 0e423489a..ac43a4034 100644 --- a/tests/contrib/deepagents/test_replay.py +++ b/tests/contrib/deepagents/test_replay.py @@ -15,6 +15,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -37,7 +39,7 @@ async def run(self, input: dict) -> dict: @pytest.mark.asyncio -async def test_replay_with_plugin(env) -> None: +async def test_replay_with_plugin(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin() async with Worker( env.client, diff --git a/tests/contrib/deepagents/test_side_effects.py b/tests/contrib/deepagents/test_side_effects.py index 6dcb31968..2b32b9dee 100644 --- a/tests/contrib/deepagents/test_side_effects.py +++ b/tests/contrib/deepagents/test_side_effects.py @@ -15,6 +15,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -30,7 +32,7 @@ class TwoOpBackend: """Protocol-named ops: one async (``awrite``, as the filesystem middleware calls it) and one sync (``read``).""" - async def awrite(self, file_path: str, content: str) -> str: + async def awrite(self, file_path: str, _content: str) -> str: return f"wrote:{file_path}" def read(self, file_path: str) -> str: @@ -50,7 +52,7 @@ async def run(self) -> str: @pytest.mark.asyncio -async def test_activity_schedule_counts(env) -> None: +async def test_activity_schedule_counts(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin() async with Worker( env.client, diff --git a/tests/contrib/deepagents/test_streaming.py b/tests/contrib/deepagents/test_streaming.py index cb2685bb0..6b65aa0af 100644 --- a/tests/contrib/deepagents/test_streaming.py +++ b/tests/contrib/deepagents/test_streaming.py @@ -16,6 +16,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -48,7 +50,7 @@ async def run(self, prompt: str) -> str: @pytest.mark.asyncio -async def test_stream_chunks_published(env) -> None: +async def test_stream_chunks_published(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin( model_provider=mock_model_provider(["Streamed answer."]), streaming_topic="da-stream-topic", @@ -76,7 +78,7 @@ async def test_stream_chunks_published(env) -> None: @pytest.mark.asyncio -async def test_batch_interval_coalesces(env) -> None: +async def test_batch_interval_coalesces(env: WorkflowEnvironment) -> None: # A custom batch interval is threaded into the streaming activity that # coalesces chunks; streaming still returns the aggregated message. plugin = DeepAgentsPlugin( diff --git a/tests/contrib/deepagents/test_subagents.py b/tests/contrib/deepagents/test_subagents.py index 09fe687d2..c00cf2c73 100644 --- a/tests/contrib/deepagents/test_subagents.py +++ b/tests/contrib/deepagents/test_subagents.py @@ -11,6 +11,11 @@ model call went through an activity. """ +# The deepagents / langchain optional deps cannot install on Python 3.10 +# (deepagents pins >=3.11), so pyright cannot resolve their imports there; +# runtime collection is guarded by importorskip below. +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false + from __future__ import annotations import sys @@ -18,6 +23,8 @@ import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -60,7 +67,7 @@ async def run(self, question: str) -> str: @pytest.mark.asyncio -async def test_subagent_calls_route_to_activities(env) -> None: +async def test_subagent_calls_route_to_activities(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin( model_provider=mock_model_provider(["Coordinated answer."]), ) diff --git a/tests/contrib/deepagents/test_tools.py b/tests/contrib/deepagents/test_tools.py index 5836a654f..89cb23d6c 100644 --- a/tests/contrib/deepagents/test_tools.py +++ b/tests/contrib/deepagents/test_tools.py @@ -5,16 +5,24 @@ expected activity. """ +# The deepagents / langchain optional deps cannot install on Python 3.10 +# (deepagents pins >=3.11), so pyright cannot resolve their imports there; +# runtime collection is guarded by importorskip below. +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false + from __future__ import annotations import sys import uuid +from collections.abc import Sequence from datetime import timedelta from types import SimpleNamespace -from typing import Any, Sequence +from typing import Any import pytest +from temporalio.testing import WorkflowEnvironment + pytestmark = pytest.mark.skipif( sys.version_info < (3, 11), reason="deepagents requires Python >= 3.11" ) @@ -73,7 +81,7 @@ def get_weather(city: str) -> str: @pytest.mark.asyncio -async def test_activity_as_tool(env) -> None: +async def test_activity_as_tool(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin() async with Worker( env.client, @@ -96,7 +104,7 @@ async def test_activity_as_tool(env) -> None: @pytest.mark.asyncio -async def test_tool_as_activity(env) -> None: +async def test_tool_as_activity(env: WorkflowEnvironment) -> None: plugin = DeepAgentsPlugin() async with Worker( env.client, @@ -117,7 +125,7 @@ async def test_tool_as_activity(env) -> None: assert counts[INVOKE_TOOL] == 1, counts -def test_builtin_tool_in_workflow(recwarn) -> None: +def test_builtin_tool_in_workflow(recwarn: pytest.WarningsRecorder) -> None: # Built-in tool names never warn (they are pure, in-workflow); an unwrapped # user tool does warn so the Workflow-vs-Activity choice is conscious. warn_unwrapped_tools([SimpleNamespace(name="write_todos")]) @@ -153,7 +161,9 @@ async def run(self, city: str) -> str: @pytest.mark.asyncio -async def test_wrapped_tool_result_pairs_with_model_tool_call_id(env) -> None: +async def test_wrapped_tool_result_pairs_with_model_tool_call_id( + env: WorkflowEnvironment, +) -> None: """The tool_result the model sees on turn 2 must carry the model's OWN tool_call_id. Regression: ``tool_as_activity`` returned the activity-built ``ToolMessage`` whose workflow-generated id a real provider (Anthropic) @@ -171,7 +181,13 @@ class RecordingModel(FakeModel): def __init__(self, responses: Sequence[Any]) -> None: super().__init__(responses) - async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): + async def _agenerate( + self, + messages: list[Any], + stop: list[str] | None = None, + run_manager: Any = None, + **kwargs: Any, + ) -> Any: _captured_requests.append(list(messages)) return await super()._agenerate( messages, stop=stop, run_manager=run_manager, **kwargs From 0198dae2bcfefe442def16cbe0bc6dfc494c460e Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 14:43:24 -0500 Subject: [PATCH 5/7] Fix pydoctor docstring syntax and 3.10 implicit-relative lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API-docs build rejects an inline literal whose end-string is followed by a letter (``Serializable``s), and cannot resolve a :class: link to the package re-export, so both become plain prose / literals. On 3.10, where the real deepagents package cannot install, basedpyright resolves `from deepagents import ...` in tests/contrib/deepagents/ implicitly relative to the same-named test directory — extend the existing file-level directive; Python 3 has no implicit relative imports at runtime and collection is already importorskip-gated. --- temporalio/contrib/deepagents/_activity.py | 2 +- temporalio/contrib/deepagents/_serde.py | 2 +- temporalio/contrib/deepagents/testing.py | 3 ++- tests/contrib/deepagents/test_backends.py | 1 + tests/contrib/deepagents/test_checkpointer.py | 1 + tests/contrib/deepagents/test_hitl.py | 1 + tests/contrib/deepagents/test_native_e2e.py | 1 + tests/contrib/deepagents/test_subagents.py | 1 + tests/contrib/deepagents/test_tools.py | 1 + 9 files changed, 10 insertions(+), 3 deletions(-) diff --git a/temporalio/contrib/deepagents/_activity.py b/temporalio/contrib/deepagents/_activity.py index 93892ab3f..e0030cd4a 100644 --- a/temporalio/contrib/deepagents/_activity.py +++ b/temporalio/contrib/deepagents/_activity.py @@ -221,7 +221,7 @@ def _default_model_provider(model_name: str) -> Any: class DeepAgentActivities: """Holds the worker-side dependencies and exposes the four activities. - An instance is created by :class:`~temporalio.contrib.deepagents.DeepAgentsPlugin` + An instance is created by ``DeepAgentsPlugin`` and its bound methods are registered on the worker. """ diff --git a/temporalio/contrib/deepagents/_serde.py b/temporalio/contrib/deepagents/_serde.py index 61af4c43e..13ed0d1c6 100644 --- a/temporalio/contrib/deepagents/_serde.py +++ b/temporalio/contrib/deepagents/_serde.py @@ -160,7 +160,7 @@ def dump_backend_result(value: Any) -> Any: Backend protocol results (``WriteResult`` / ``ReadResult`` / ``GrepResult`` and their nested ``FileInfo`` / ``GrepMatch`` items, …) are plain - dataclasses — not LangChain ``Serializable``s — and the filesystem + dataclasses — not LangChain ``Serializable`` objects — and the filesystem middleware reads their ATTRIBUTES in-workflow, so a plain JSON round-trip (which decays them to dicts) breaks the seam at the first real backend op. Tag deepagents dataclasses with their import path so diff --git a/temporalio/contrib/deepagents/testing.py b/temporalio/contrib/deepagents/testing.py index 7d84166aa..749b1ab96 100644 --- a/temporalio/contrib/deepagents/testing.py +++ b/temporalio/contrib/deepagents/testing.py @@ -4,7 +4,8 @@ or a paid API key. This module ships: * :class:`FakeModel` — a real ``BaseChatModel`` returning scripted replies (plain - text or full ``AIMessage``s carrying ``tool_calls``), cycling when exhausted; + text or full ``AIMessage`` objects carrying ``tool_calls``), cycling when + exhausted; * :func:`fake_model_factory` — a one-liner for the common text-only case; * :func:`mock_model_provider` — a ``model_provider`` (name → model) you pass to ``DeepAgentsPlugin(model_provider=...)`` so the model activity runs offline; diff --git a/tests/contrib/deepagents/test_backends.py b/tests/contrib/deepagents/test_backends.py index 40dcca358..325bb4a2e 100644 --- a/tests/contrib/deepagents/test_backends.py +++ b/tests/contrib/deepagents/test_backends.py @@ -14,6 +14,7 @@ # (deepagents pins >=3.11), so pyright cannot resolve their imports there; # runtime collection is guarded by importorskip below. # pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# pyright: reportImplicitRelativeImport=false from __future__ import annotations diff --git a/tests/contrib/deepagents/test_checkpointer.py b/tests/contrib/deepagents/test_checkpointer.py index c5969f6b7..649f4a486 100644 --- a/tests/contrib/deepagents/test_checkpointer.py +++ b/tests/contrib/deepagents/test_checkpointer.py @@ -11,6 +11,7 @@ # (deepagents pins >=3.11), so pyright cannot resolve their imports there; # runtime collection is guarded by importorskip below. # pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# pyright: reportImplicitRelativeImport=false from __future__ import annotations diff --git a/tests/contrib/deepagents/test_hitl.py b/tests/contrib/deepagents/test_hitl.py index befbe46aa..d80b9701d 100644 --- a/tests/contrib/deepagents/test_hitl.py +++ b/tests/contrib/deepagents/test_hitl.py @@ -19,6 +19,7 @@ # (deepagents pins >=3.11), so pyright cannot resolve their imports there; # runtime collection is guarded by importorskip below. # pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# pyright: reportImplicitRelativeImport=false from __future__ import annotations diff --git a/tests/contrib/deepagents/test_native_e2e.py b/tests/contrib/deepagents/test_native_e2e.py index ce0783c3c..072768c36 100644 --- a/tests/contrib/deepagents/test_native_e2e.py +++ b/tests/contrib/deepagents/test_native_e2e.py @@ -15,6 +15,7 @@ # (deepagents pins >=3.11), so pyright cannot resolve their imports there; # runtime collection is guarded by importorskip below. # pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# pyright: reportImplicitRelativeImport=false from __future__ import annotations diff --git a/tests/contrib/deepagents/test_subagents.py b/tests/contrib/deepagents/test_subagents.py index c00cf2c73..bd34b6725 100644 --- a/tests/contrib/deepagents/test_subagents.py +++ b/tests/contrib/deepagents/test_subagents.py @@ -15,6 +15,7 @@ # (deepagents pins >=3.11), so pyright cannot resolve their imports there; # runtime collection is guarded by importorskip below. # pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# pyright: reportImplicitRelativeImport=false from __future__ import annotations diff --git a/tests/contrib/deepagents/test_tools.py b/tests/contrib/deepagents/test_tools.py index 89cb23d6c..fc493cdbd 100644 --- a/tests/contrib/deepagents/test_tools.py +++ b/tests/contrib/deepagents/test_tools.py @@ -9,6 +9,7 @@ # (deepagents pins >=3.11), so pyright cannot resolve their imports there; # runtime collection is guarded by importorskip below. # pyright: reportMissingImports=false, reportAttributeAccessIssue=false +# pyright: reportImplicitRelativeImport=false from __future__ import annotations From 3283d879b8443cc085083966688314bbc52d8b0b Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 15:06:05 -0500 Subject: [PATCH 6/7] Bind deepagents test symbols via importorskip, not static imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round's file-level directive used reportImplicitRelativeImport, which is basedpyright-only vocabulary — plain pyright hard-errors on unknown rules in pyright comments, taking every lint leg down. Rather than juggle two checkers' rule sets, drop the static `from deepagents import ...` lines from the tests entirely: symbols now bind off the module object pytest.importorskip already returns, which is dynamically typed for every checker, resolves nothing against the same-named test directory on 3.10, and lets the directives (and the now-moot protocol cast) be deleted outright. --- tests/contrib/deepagents/test_backends.py | 24 ++++++++----------- tests/contrib/deepagents/test_checkpointer.py | 13 ++++------ tests/contrib/deepagents/test_hitl.py | 13 +++++----- tests/contrib/deepagents/test_native_e2e.py | 13 +++++----- tests/contrib/deepagents/test_subagents.py | 14 +++++------ tests/contrib/deepagents/test_tools.py | 14 +++++------ 6 files changed, 39 insertions(+), 52 deletions(-) diff --git a/tests/contrib/deepagents/test_backends.py b/tests/contrib/deepagents/test_backends.py index 325bb4a2e..813041e6e 100644 --- a/tests/contrib/deepagents/test_backends.py +++ b/tests/contrib/deepagents/test_backends.py @@ -10,19 +10,12 @@ ``deepagents.StateBackend`` when it is importable. """ -# The deepagents / langchain optional deps cannot install on Python 3.10 -# (deepagents pins >=3.11), so pyright cannot resolve their imports there; -# runtime collection is guarded by importorskip below. -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# pyright: reportImplicitRelativeImport=false - from __future__ import annotations import sys import uuid from datetime import timedelta from pathlib import Path -from typing import cast import pytest @@ -70,10 +63,15 @@ async def run(self, path: str) -> str: return f"{sync_out}|{async_out}" -with workflow.unsafe.imports_passed_through(): - from deepagents import create_deep_agent - from deepagents.backends import FilesystemBackend, StateBackend - from deepagents.backends.protocol import BackendProtocol +# Bind deepagents symbols off the module importorskip returns: a static +# `from deepagents import ...` cannot resolve on Python 3.10 (deepagents +# needs >= 3.11), and with the package absent the type checkers mis-resolve +# the name against this same-named test directory. +_deepagents_mod = pytest.importorskip("deepagents") +_backends_mod = pytest.importorskip("deepagents.backends") +create_deep_agent = _deepagents_mod.create_deep_agent +FilesystemBackend = _backends_mod.FilesystemBackend +StateBackend = _backends_mod.StateBackend # A state-only backend is pure workflow state and must NOT schedule an activity. @@ -104,9 +102,7 @@ async def run(self, root_dir: str) -> str: ) agent = create_deep_agent( model="anthropic:claude-sonnet-4-5", - # TemporalBackend satisfies the protocol structurally via - # __getattr__ forwarding, which nominal type checking can't see. - backend=cast(BackendProtocol, cast(object, backend)), + backend=backend, system_prompt="Write the note, read it back, then report it.", ) result = await agent.ainvoke( diff --git a/tests/contrib/deepagents/test_checkpointer.py b/tests/contrib/deepagents/test_checkpointer.py index 649f4a486..851bbb018 100644 --- a/tests/contrib/deepagents/test_checkpointer.py +++ b/tests/contrib/deepagents/test_checkpointer.py @@ -7,12 +7,6 @@ and points at the snapshot + continue-as-new path. """ -# The deepagents / langchain optional deps cannot install on Python 3.10 -# (deepagents pins >=3.11), so pyright cannot resolve their imports there; -# runtime collection is guarded by importorskip below. -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# pyright: reportImplicitRelativeImport=false - from __future__ import annotations import sys @@ -32,8 +26,11 @@ from temporalio import workflow from temporalio.contrib.deepagents.workflow import warn_durable_checkpointer -with workflow.unsafe.imports_passed_through(): - from deepagents import create_deep_agent +# Bind deepagents symbols off the module importorskip returns: a static +# `from deepagents import ...` cannot resolve on Python 3.10 (deepagents +# needs >= 3.11), and with the package absent the type checkers mis-resolve +# the name against this same-named test directory. +create_deep_agent = pytest.importorskip("deepagents").create_deep_agent class _DurableSaver: diff --git a/tests/contrib/deepagents/test_hitl.py b/tests/contrib/deepagents/test_hitl.py index d80b9701d..6124d41f1 100644 --- a/tests/contrib/deepagents/test_hitl.py +++ b/tests/contrib/deepagents/test_hitl.py @@ -15,12 +15,6 @@ Temporal pattern for state shared between the run method and its handlers. """ -# The deepagents / langchain optional deps cannot install on Python 3.10 -# (deepagents pins >=3.11), so pyright cannot resolve their imports there; -# runtime collection is guarded by importorskip below. -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# pyright: reportImplicitRelativeImport=false - from __future__ import annotations import asyncio @@ -44,8 +38,13 @@ from temporalio import workflow # noqa: E402 from temporalio.worker import Worker # noqa: E402 +# Bind deepagents symbols off the module importorskip returns: a static +# `from deepagents import ...` cannot resolve on Python 3.10 (deepagents +# needs >= 3.11), and with the package absent the type checkers mis-resolve +# the name against this same-named test directory. +create_deep_agent = pytest.importorskip("deepagents").create_deep_agent + with workflow.unsafe.imports_passed_through(): - from deepagents import create_deep_agent from langchain_core.messages import AIMessage from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import InMemorySaver diff --git a/tests/contrib/deepagents/test_native_e2e.py b/tests/contrib/deepagents/test_native_e2e.py index 072768c36..29b373da6 100644 --- a/tests/contrib/deepagents/test_native_e2e.py +++ b/tests/contrib/deepagents/test_native_e2e.py @@ -11,12 +11,6 @@ docs-only checkout — there is no env-var gate. """ -# The deepagents / langchain optional deps cannot install on Python 3.10 -# (deepagents pins >=3.11), so pyright cannot resolve their imports there; -# runtime collection is guarded by importorskip below. -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# pyright: reportImplicitRelativeImport=false - from __future__ import annotations import sys @@ -38,8 +32,13 @@ from temporalio.worker import Worker # noqa: E402 from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 +# Bind deepagents symbols off the module importorskip returns: a static +# `from deepagents import ...` cannot resolve on Python 3.10 (deepagents +# needs >= 3.11), and with the package absent the type checkers mis-resolve +# the name against this same-named test directory. +create_deep_agent = pytest.importorskip("deepagents").create_deep_agent + with workflow.unsafe.imports_passed_through(): - from deepagents import create_deep_agent from langchain_core.messages import AIMessage from temporalio.contrib.deepagents import DeepAgentsPlugin, tool_as_activity diff --git a/tests/contrib/deepagents/test_subagents.py b/tests/contrib/deepagents/test_subagents.py index bd34b6725..49a407e9d 100644 --- a/tests/contrib/deepagents/test_subagents.py +++ b/tests/contrib/deepagents/test_subagents.py @@ -11,12 +11,6 @@ model call went through an activity. """ -# The deepagents / langchain optional deps cannot install on Python 3.10 -# (deepagents pins >=3.11), so pyright cannot resolve their imports there; -# runtime collection is guarded by importorskip below. -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# pyright: reportImplicitRelativeImport=false - from __future__ import annotations import sys @@ -37,9 +31,13 @@ from temporalio.worker import Worker # noqa: E402 from tests.contrib.deepagents.helpers import count_scheduled_activities # noqa: E402 -with workflow.unsafe.imports_passed_through(): - from deepagents import create_deep_agent +# Bind deepagents symbols off the module importorskip returns: a static +# `from deepagents import ...` cannot resolve on Python 3.10 (deepagents +# needs >= 3.11), and with the package absent the type checkers mis-resolve +# the name against this same-named test directory. +create_deep_agent = pytest.importorskip("deepagents").create_deep_agent +with workflow.unsafe.imports_passed_through(): from temporalio.contrib.deepagents import DeepAgentsPlugin from temporalio.contrib.deepagents.testing import mock_model_provider diff --git a/tests/contrib/deepagents/test_tools.py b/tests/contrib/deepagents/test_tools.py index fc493cdbd..1fc1ff212 100644 --- a/tests/contrib/deepagents/test_tools.py +++ b/tests/contrib/deepagents/test_tools.py @@ -5,12 +5,6 @@ expected activity. """ -# The deepagents / langchain optional deps cannot install on Python 3.10 -# (deepagents pins >=3.11), so pyright cannot resolve their imports there; -# runtime collection is guarded by importorskip below. -# pyright: reportMissingImports=false, reportAttributeAccessIssue=false -# pyright: reportImplicitRelativeImport=false - from __future__ import annotations import sys @@ -44,6 +38,12 @@ INVOKE_TOOL = "deepagents.invoke_tool" +# Bind deepagents symbols off the module importorskip returns: a static +# `from deepagents import ...` cannot resolve on Python 3.10 (deepagents +# needs >= 3.11), and with the package absent the type checkers mis-resolve +# the name against this same-named test directory. +create_deep_agent = pytest.importorskip("deepagents").create_deep_agent + def pairing_weather(city: str) -> str: """Return the weather for a city.""" @@ -145,8 +145,6 @@ def test_builtin_tool_in_workflow(recwarn: pytest.WarningsRecorder) -> None: class ToolCallIdPairingWorkflow: @workflow.run async def run(self, city: str) -> str: - from deepagents import create_deep_agent - weather_tool = tool_as_activity( pairing_weather, start_to_close_timeout=timedelta(seconds=10) ) From a54d445352dc1efe23057dfe2e2a8eba7d95dbb9 Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 18:31:59 -0500 Subject: [PATCH 7/7] Tighten worker-lifetime cleanup in the deepagents plugin - Await the cancelled heartbeat task so no pending task outlives an activity at event-loop shutdown. - Catch only ImportError when installing the create_deep_agent patches and include the original error in the warning; genuine installation bugs now surface at worker startup. - Keep the LangSmith aio_to_thread override installed for the process lifetime: the override slot is global and resetting it would strip a composed contrib.langsmith plugin's identical override. - Unregister a TemporalBackend's registry entry when the wrapper is garbage-collected, identity-guarded so a replay's re-registration of the same deterministic ref survives the evicted wrapper's cleanup. --- temporalio/contrib/deepagents/_activity.py | 5 +++ temporalio/contrib/deepagents/_plugin.py | 46 ++++++++++++---------- temporalio/contrib/deepagents/_tools.py | 29 +++++++++++++- tests/contrib/deepagents/test_backends.py | 33 ++++++++++++++++ 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/temporalio/contrib/deepagents/_activity.py b/temporalio/contrib/deepagents/_activity.py index e0030cd4a..3c1501a42 100644 --- a/temporalio/contrib/deepagents/_activity.py +++ b/temporalio/contrib/deepagents/_activity.py @@ -150,6 +150,11 @@ async def beat() -> None: finally: if beat_task is not None: beat_task.cancel() + # Let the cancellation land before returning so no pending task + # outlives the activity (a bare ``cancel()`` leaves the task to + # be destroyed while pending if the loop shuts down first). + # ``asyncio.wait`` never re-raises the task's CancelledError. + await asyncio.wait([beat_task]) return wrapped diff --git a/temporalio/contrib/deepagents/_plugin.py b/temporalio/contrib/deepagents/_plugin.py index e7b17ee4b..3b95fcdaf 100644 --- a/temporalio/contrib/deepagents/_plugin.py +++ b/temporalio/contrib/deepagents/_plugin.py @@ -191,14 +191,20 @@ async def _run_context(self) -> AsyncIterator[None]: _model.install_model_patch() _tools.install_backend_async_patch() patched = True - except Exception: # LangChain / deepagents not importable on this worker + except ImportError as exc: # LangChain / deepagents absent on this worker + # Deliberately ImportError only: a *missing* optional dependency + # must not stop the worker, but a genuine patch-installation bug + # (e.g. upstream renaming the seam raises AttributeError) must + # surface at startup, not degrade into silent in-workflow calls. warnings.warn( - "DeepAgentsPlugin could not patch create_deep_agent; use explicit " - "TemporalModel(...) instances to route model calls through " - "activities.", + f"DeepAgentsPlugin could not patch create_deep_agent ({exc}); " + "use explicit TemporalModel(...) instances to route model calls " + "through activities.", stacklevel=2, ) - langsmith_patched = _install_langsmith_temporal_override() + # Installed for the life of the process, never uninstalled — see + # _install_langsmith_temporal_override for why. + _install_langsmith_temporal_override() try: yield finally: @@ -208,8 +214,6 @@ async def _run_context(self) -> AsyncIterator[None]: _model.uninstall_model_patch() _tools.uninstall_backend_async_patch() - if langsmith_patched: - _uninstall_langsmith_temporal_override() # -- worker config ------------------------------------------------------- @@ -246,28 +250,28 @@ async def _temporal_aio_to_thread( return ctx.run(func, *args, **kwargs) -def _install_langsmith_temporal_override() -> bool: - """Route LangSmith's thread hop inline while in a workflow. Returns success. +def _install_langsmith_temporal_override() -> None: + """Route LangSmith's thread hop inline while in a workflow. ``set_runtime_overrides`` mutates a module global in the sandbox-passthrough ``langsmith`` package, so the in-workflow tracer sees it too. No-op (and - harmless) when LangSmith is not installed. + harmless) when LangSmith is not installed or too old to expose + ``set_runtime_overrides``. + + The override is deliberately installed for the life of the process and + never uninstalled: LangSmith exposes a single process-wide override slot + (each ``set_runtime_overrides`` call replaces it wholesale), and + ``temporalio.contrib.langsmith`` installs a behaviorally identical override + exactly once, without reinstalling. Resetting the slot on this worker's + shutdown would therefore permanently strip a composed LangSmith plugin's + workflow-safety override. Leaving it installed is safe: the override defers + to LangSmith's default thread hop whenever ``workflow.in_workflow()`` is + false, so it is inert outside workflows. """ try: import langsmith langsmith.set_runtime_overrides(aio_to_thread=_temporal_aio_to_thread) - return True - except Exception: - return False - - -def _uninstall_langsmith_temporal_override() -> None: - """Restore LangSmith's default runtime behavior.""" - try: - import langsmith - - langsmith.set_runtime_overrides() except Exception: pass diff --git a/temporalio/contrib/deepagents/_tools.py b/temporalio/contrib/deepagents/_tools.py index c3956cfc8..1235224c6 100644 --- a/temporalio/contrib/deepagents/_tools.py +++ b/temporalio/contrib/deepagents/_tools.py @@ -27,8 +27,10 @@ from __future__ import annotations import importlib +import threading import uuid as _uuid import warnings +import weakref from collections.abc import Mapping from datetime import timedelta from functools import wraps @@ -53,6 +55,9 @@ _TOOL_REGISTRY: dict[str, "BaseTool"] = {} _BACKEND_REGISTRY: dict[str, Any] = {} +# Serializes registration against the GC-time unregister in +# _unregister_backend, which may run on another thread. +_BACKEND_REGISTRY_LOCK = threading.Lock() # Worker-wide default activity options for tools wrapped with tool_as_activity, # set by the plugin. Not per-workflow state: fixed for the worker's lifetime, and @@ -144,7 +149,23 @@ def get_registered_tool(name: str) -> BaseTool | None: def register_backend(ref: str, backend: Any) -> None: """Record a backend so :meth:`DeepAgentActivities.backend_op` can reach it.""" - _BACKEND_REGISTRY[ref] = backend + with _BACKEND_REGISTRY_LOCK: + _BACKEND_REGISTRY[ref] = backend + + +def _unregister_backend(ref: str, inner: Any) -> None: + """Drop ``ref`` from the registry if it still maps to ``inner``. + + GC hook for :class:`TemporalBackend` (via ``weakref.finalize``): a wrapper + is typically constructed per workflow run, so without cleanup a long-lived + worker accumulates one registry entry per run. The identity guard is + load-bearing: refs are deterministic per run, so after a cache eviction a + replay re-registers the *same* ref with a fresh inner backend — the evicted + wrapper's finalizer must not remove that live registration. + """ + with _BACKEND_REGISTRY_LOCK: + if _BACKEND_REGISTRY.get(ref) is inner: + del _BACKEND_REGISTRY[ref] def registered_backends() -> dict[str, Any]: @@ -459,6 +480,12 @@ def __init__( self._opts: dict[str, Any] = dict(activity_options or {}) self._opts.setdefault("start_to_close_timeout", timedelta(minutes=1)) register_backend(self._ref, inner) + # Balance the registration when this wrapper is garbage-collected + # (workflow completion / cache eviction) so per-run backends do not + # accumulate in the worker-global registry. The finalizer captures + # (ref, inner) — not ``self`` — so it cannot keep the wrapper alive, + # and it only removes its own registration (see _unregister_backend). + self._finalizer = weakref.finalize(self, _unregister_backend, self._ref, inner) async def _dispatch(self, op: str, *args: Any, **kwargs: Any) -> Any: from temporalio.contrib.deepagents.workflow import call_backend_op diff --git a/tests/contrib/deepagents/test_backends.py b/tests/contrib/deepagents/test_backends.py index 813041e6e..b32a6f12b 100644 --- a/tests/contrib/deepagents/test_backends.py +++ b/tests/contrib/deepagents/test_backends.py @@ -12,6 +12,7 @@ from __future__ import annotations +import gc import sys import uuid from datetime import timedelta @@ -29,6 +30,10 @@ from temporalio import workflow from temporalio.contrib.deepagents import DeepAgentsPlugin, TemporalBackend +from temporalio.contrib.deepagents._tools import ( + register_backend, + registered_backends, +) from temporalio.worker import Worker from tests.contrib.deepagents.helpers import count_scheduled_activities @@ -134,6 +139,34 @@ async def test_temporal_backend_op_activity(env: WorkflowEnvironment) -> None: assert counts[BACKEND_OP] == 2, counts +def test_temporal_backend_unregisters_on_gc() -> None: + # A wrapper is typically constructed per workflow run; its registry entry + # must not outlive it, or a long-lived worker leaks one entry per run. + inner = RecordingBackend() + before = set(registered_backends()) + wrapper = TemporalBackend(inner) + (ref,) = set(registered_backends()) - before + assert registered_backends()[ref] is inner + del wrapper + gc.collect() + assert ref not in registered_backends() + + +def test_temporal_backend_gc_keeps_reregistered_ref() -> None: + # Refs are deterministic per run: after a cache eviction, a replay + # re-registers the SAME ref with a fresh inner backend. The evicted + # wrapper's GC cleanup must leave that live registration alone. + before = set(registered_backends()) + wrapper = TemporalBackend(RecordingBackend()) + (ref,) = set(registered_backends()) - before + replacement = RecordingBackend() + register_backend(ref, replacement) + del wrapper + gc.collect() + assert registered_backends().get(ref) is replacement + registered_backends().pop(ref, None) + + @pytest.mark.asyncio async def test_state_backend_in_workflow(env: WorkflowEnvironment) -> None: # A state-only backend is pure workflow state and must NOT schedule an