From 68ab699c074a7e4589a8f9eea5c0ec60b4599df8 Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 14 Jul 2026 11:15:09 -0500 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 From af3a4c67b50258c34f10e41db31b9a3c76fc230a Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 15:15:52 -0500 Subject: [PATCH 08/16] workflow_streams: add current_workflow_stream() accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The langgraph plugin located the workflow's stream by importing the private publish-signal name and introspecting the registered handler's __self__ — fragile coupling to another contrib package's internals. Expose one audited accessor that owns the introspection (with an isinstance guard) and migrate langgraph's presence check and instance fetch onto it. The interceptor's error message is unchanged; the workflow wrapper gains a defensive error on a path the interceptor already makes unreachable. --- temporalio/contrib/langgraph/_interceptor.py | 7 ++-- temporalio/contrib/langgraph/_workflow.py | 12 +++++-- .../contrib/workflow_streams/__init__.py | 6 +++- .../contrib/workflow_streams/_stream.py | 18 ++++++++++ .../workflow_streams/test_workflow_streams.py | 36 +++++++++++++++++++ 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/temporalio/contrib/langgraph/_interceptor.py b/temporalio/contrib/langgraph/_interceptor.py index f68d9d45d..2409481f9 100644 --- a/temporalio/contrib/langgraph/_interceptor.py +++ b/temporalio/contrib/langgraph/_interceptor.py @@ -11,7 +11,7 @@ from temporalio import workflow from temporalio.contrib.langgraph._activity import clear_store_warning -from temporalio.contrib.workflow_streams._stream import _PUBLISH_SIGNAL +from temporalio.contrib.workflow_streams import current_workflow_stream from temporalio.worker import ( ExecuteWorkflowInput, Interceptor, @@ -54,10 +54,7 @@ def init(self, outbound: WorkflowOutboundInterceptor) -> None: super().init(outbound) async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any: - if ( - streaming_topic is not None - and workflow.get_signal_handler(_PUBLISH_SIGNAL) is None - ): + if streaming_topic is not None and current_workflow_stream() is None: raise RuntimeError( f"LangGraphPlugin was configured with " f"streaming_topic={streaming_topic!r}, but workflow " diff --git a/temporalio/contrib/langgraph/_workflow.py b/temporalio/contrib/langgraph/_workflow.py index 43b3d06ae..93d303e98 100644 --- a/temporalio/contrib/langgraph/_workflow.py +++ b/temporalio/contrib/langgraph/_workflow.py @@ -13,7 +13,7 @@ from langgraph._internal._constants import CONFIG_KEY_RUNTIME from temporalio import workflow -from temporalio.contrib.workflow_streams._stream import _PUBLISH_SIGNAL +from temporalio.contrib.workflow_streams import current_workflow_stream def wrap_workflow( @@ -65,8 +65,14 @@ async def run(stream_writer: Callable[[Any], None] | None) -> Any: if streaming_topic is None: return await run(stream_writer=None) - publish_handler = workflow.get_signal_handler(_PUBLISH_SIGNAL) - stream = getattr(publish_handler, "__self__") + stream = current_workflow_stream() + if stream is None: + # The interceptor validates stream presence at workflow start, so + # this is unreachable in a plugin-managed workflow; guard anyway. + raise RuntimeError( + "streaming_topic is configured but the workflow has no " + "WorkflowStream; construct WorkflowStream() in @workflow.init." + ) topic = stream.topic(streaming_topic) return await run(stream_writer=topic.publish) diff --git a/temporalio/contrib/workflow_streams/__init__.py b/temporalio/contrib/workflow_streams/__init__.py index 41f670f0c..5a756281b 100644 --- a/temporalio/contrib/workflow_streams/__init__.py +++ b/temporalio/contrib/workflow_streams/__init__.py @@ -13,7 +13,10 @@ """ from temporalio.contrib.workflow_streams._client import WorkflowStreamClient -from temporalio.contrib.workflow_streams._stream import WorkflowStream +from temporalio.contrib.workflow_streams._stream import ( + WorkflowStream, + current_workflow_stream, +) from temporalio.contrib.workflow_streams._topic_handle import ( TopicHandle, WorkflowTopicHandle, @@ -40,4 +43,5 @@ "WorkflowStreamItem", "WorkflowStreamState", "WorkflowTopicHandle", + "current_workflow_stream", ] diff --git a/temporalio/contrib/workflow_streams/_stream.py b/temporalio/contrib/workflow_streams/_stream.py index ae8608c3b..33a5346b9 100644 --- a/temporalio/contrib/workflow_streams/_stream.py +++ b/temporalio/contrib/workflow_streams/_stream.py @@ -59,6 +59,24 @@ T = TypeVar("T") +def current_workflow_stream() -> WorkflowStream | None: + """Return the current workflow's :py:class:`WorkflowStream`, if any. + + Must be called inside a workflow. A workflow that constructed a + ``WorkflowStream`` (in ``@workflow.init``) registers the stream's publish + signal handler; that registration is the marker this accessor reads. + Returns ``None`` when no stream was constructed. + + .. warning:: + This function is experimental and may change in future versions. + """ + handler = workflow.get_signal_handler(_PUBLISH_SIGNAL) + if handler is None: + return None + stream = getattr(handler, "__self__", None) + return stream if isinstance(stream, WorkflowStream) else None + + def _payload_wire_size(payload: Payload, topic: str) -> int: """Approximate poll-response contribution of a single item. diff --git a/tests/contrib/workflow_streams/test_workflow_streams.py b/tests/contrib/workflow_streams/test_workflow_streams.py index 12026ff1a..da9c1d173 100644 --- a/tests/contrib/workflow_streams/test_workflow_streams.py +++ b/tests/contrib/workflow_streams/test_workflow_streams.py @@ -45,6 +45,7 @@ WorkflowStreamItem, WorkflowStreamState, WorkflowTopicHandle, + current_workflow_stream, ) from temporalio.contrib.workflow_streams._types import _encode_payload from temporalio.converter import DataConverter @@ -2788,3 +2789,38 @@ async def broker_started() -> bool: await broker_handle.signal("close") result = await caller_handle.result() assert result == "done" + + +@workflow.defn +class StreamAccessorWorkflow: + @workflow.init + def __init__(self) -> None: + self.stream = WorkflowStream() + + @workflow.run + async def run(self) -> bool: + return current_workflow_stream() is self.stream + + +@workflow.defn +class NoStreamAccessorWorkflow: + @workflow.run + async def run(self) -> bool: + return current_workflow_stream() is None + + +async def test_current_workflow_stream_accessor(client: Client) -> None: + """The accessor returns the constructed stream instance, else None.""" + async with new_worker( + client, StreamAccessorWorkflow, NoStreamAccessorWorkflow + ) as worker: + assert await client.execute_workflow( + StreamAccessorWorkflow.run, + id=f"stream-accessor-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) + assert await client.execute_workflow( + NoStreamAccessorWorkflow.run, + id=f"stream-accessor-none-{uuid.uuid4()}", + task_queue=worker.task_queue, + ) From 7809b45e6fea1cf48b0c42a187436d6975641b3e Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 15:19:34 -0500 Subject: [PATCH 09/16] contrib: add internal _langchain core; share the aio_to_thread override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LangSmith workflow-safety override for @traceable's aio_to_thread seam existed twice — in contrib.langsmith and contrib.deepagents — and the two copies interact through langsmith's single process-wide override slot (an earlier deepagents uninstall would have stripped the langsmith plugin's override permanently). Introduce temporalio.contrib._langchain, an internal shared package for the LangChain-family plugins (import-free __init__, lazy third-party imports, ships unconditionally like other contrib infrastructure), and move the override there with langsmith's install-once semantics: failures propagate and leave the flag unset so a retry can succeed. langsmith calls the shared installer bare (its dependency is guaranteed); deepagents keeps its exception-tolerant wrapper for workers without langsmith installed. --- temporalio/contrib/_langchain/__init__.py | 10 +++ .../contrib/_langchain/_aio_to_thread.py | 59 ++++++++++++++++ temporalio/contrib/deepagents/_plugin.py | 47 +++---------- temporalio/contrib/langsmith/_interceptor.py | 48 ++----------- tests/contrib/langchain_shared/__init__.py | 0 .../langchain_shared/test_aio_override.py | 70 +++++++++++++++++++ 6 files changed, 151 insertions(+), 83 deletions(-) create mode 100644 temporalio/contrib/_langchain/__init__.py create mode 100644 temporalio/contrib/_langchain/_aio_to_thread.py create mode 100644 tests/contrib/langchain_shared/__init__.py create mode 100644 tests/contrib/langchain_shared/test_aio_override.py diff --git a/temporalio/contrib/_langchain/__init__.py b/temporalio/contrib/_langchain/__init__.py new file mode 100644 index 000000000..0b9247c45 --- /dev/null +++ b/temporalio/contrib/_langchain/__init__.py @@ -0,0 +1,10 @@ +"""Internal shared machinery for the LangChain-family plugins. + +This package is shared infrastructure for ``temporalio.contrib.langgraph``, +``temporalio.contrib.langsmith``, and ``temporalio.contrib.deepagents``. It is +NOT a public API: names, modules, and behavior may change without notice. + +Import discipline: this ``__init__`` performs no imports, and submodules defer +every third-party import into function bodies, so the package is importable +with none of the LangChain-family distributions installed. +""" diff --git a/temporalio/contrib/_langchain/_aio_to_thread.py b/temporalio/contrib/_langchain/_aio_to_thread.py new file mode 100644 index 000000000..fc55c53dd --- /dev/null +++ b/temporalio/contrib/_langchain/_aio_to_thread.py @@ -0,0 +1,59 @@ +"""LangSmith ``aio_to_thread`` override shared by the LangChain-family plugins.""" + +from __future__ import annotations + +from typing import Any, Callable + +import temporalio.workflow + +_installed = False + + +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`` synchronously inside Temporal workflows. + + The ``@traceable`` decorator on async functions uses ``aio_to_thread()`` → + ``loop.run_in_executor()`` for run setup/teardown. The Temporal workflow + event loop does not support ``run_in_executor``. This override runs those + functions synchronously on the workflow thread when inside a workflow, + and delegates to the default implementation outside workflows. + + Registered via ``langsmith.set_runtime_overrides(aio_to_thread=...)``. + """ + if not temporalio.workflow.in_workflow(): + return await default_aio_to_thread(ctx, func, *args, **kwargs) + with temporalio.workflow.unsafe.sandbox_unrestricted(): + return ctx.run(func, *args, **kwargs) + + +def install_aio_to_thread_override() -> None: + """Install the ``aio_to_thread`` override via LangSmith's official API. + + Safe to call multiple times and from multiple plugins; the override is + installed once per process. It is deliberately never uninstalled: + LangSmith exposes a single process-wide override slot (each + ``set_runtime_overrides`` call replaces it wholesale), so resetting it on + one worker's shutdown would strip a composed plugin's still-needed + 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. + + Raises whatever the lazy ``langsmith`` import or + ``set_runtime_overrides`` call raises (e.g. ``ImportError`` when + LangSmith is absent); the installed flag stays unset on failure so a + later call can retry. + """ + global _installed # noqa: PLW0603 + if _installed: + return + import langsmith + + langsmith.set_runtime_overrides(aio_to_thread=_temporal_aio_to_thread) + _installed = True diff --git a/temporalio/contrib/deepagents/_plugin.py b/temporalio/contrib/deepagents/_plugin.py index 3b95fcdaf..1efecd85f 100644 --- a/temporalio/contrib/deepagents/_plugin.py +++ b/temporalio/contrib/deepagents/_plugin.py @@ -226,52 +226,21 @@ def configure_worker(self, config: Any) -> Any: 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() -> 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 or too old to expose + Delegates to the shared installer in ``temporalio.contrib._langchain`` + (the same override ``temporalio.contrib.langsmith`` installs, once per + process, never uninstalled — see its docstring for the rationale). No-op + (and 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 + from temporalio.contrib._langchain._aio_to_thread import ( + install_aio_to_thread_override, + ) - langsmith.set_runtime_overrides(aio_to_thread=_temporal_aio_to_thread) + install_aio_to_thread_override() except Exception: pass diff --git a/temporalio/contrib/langsmith/_interceptor.py b/temporalio/contrib/langsmith/_interceptor.py index a7eea0714..f88045fcf 100644 --- a/temporalio/contrib/langsmith/_interceptor.py +++ b/temporalio/contrib/langsmith/_interceptor.py @@ -24,6 +24,9 @@ import temporalio.worker import temporalio.workflow from temporalio.api.common.v1 import Payload +from temporalio.contrib._langchain._aio_to_thread import ( + install_aio_to_thread_override, +) from temporalio.exceptions import ApplicationError, ApplicationErrorCategory # This logger is only used in _log_future_exception, which runs on the @@ -153,49 +156,6 @@ def _get_current_run_for_propagation() -> RunTree | None: return run -# --------------------------------------------------------------------------- -# Workflow event loop safety: override @traceable's aio_to_thread -# --------------------------------------------------------------------------- - -_aio_to_thread_override_installed = False - - -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`` synchronously inside Temporal workflows. - - The ``@traceable`` decorator on async functions uses ``aio_to_thread()`` → - ``loop.run_in_executor()`` for run setup/teardown. The Temporal workflow - event loop does not support ``run_in_executor``. This override runs those - functions synchronously on the workflow thread when inside a workflow, - and delegates to the default implementation outside workflows. - - Registered via ``langsmith.set_runtime_overrides(aio_to_thread=...)``. - """ - if not temporalio.workflow.in_workflow(): - return await default_aio_to_thread(ctx, func, *args, **kwargs) - with temporalio.workflow.unsafe.sandbox_unrestricted(): - return ctx.run(func, *args, **kwargs) - - -def _install_aio_to_thread_override() -> None: - """Install the ``aio_to_thread`` override via LangSmith's official API. - - Safe to call multiple times; the override is only installed once. - """ - global _aio_to_thread_override_installed # noqa: PLW0603 - if _aio_to_thread_override_installed: - return - langsmith.set_runtime_overrides(aio_to_thread=_temporal_aio_to_thread) - _aio_to_thread_override_installed = True - - # --------------------------------------------------------------------------- # Replay safety # --------------------------------------------------------------------------- @@ -609,7 +569,7 @@ def workflow_interceptor_class( self, input: temporalio.worker.WorkflowInterceptorClassInput ) -> type[_LangSmithWorkflowInboundInterceptor]: """Return the workflow interceptor class with config bound.""" - _install_aio_to_thread_override() + install_aio_to_thread_override() config = self class InterceptorWithConfig(_LangSmithWorkflowInboundInterceptor): diff --git a/tests/contrib/langchain_shared/__init__.py b/tests/contrib/langchain_shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/contrib/langchain_shared/test_aio_override.py b/tests/contrib/langchain_shared/test_aio_override.py new file mode 100644 index 000000000..42ac32d84 --- /dev/null +++ b/tests/contrib/langchain_shared/test_aio_override.py @@ -0,0 +1,70 @@ +"""The shared LangSmith ``aio_to_thread`` override installer.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from temporalio.contrib._langchain import _aio_to_thread as mod + +pytest.importorskip("langsmith") + + +@pytest.fixture(autouse=True) +def reset_installed_flag(monkeypatch: pytest.MonkeyPatch): + """Isolate the module-level install-once flag per test.""" + monkeypatch.setattr(mod, "_installed", False) + yield + + +def test_installs_exactly_once(monkeypatch: pytest.MonkeyPatch) -> None: + import langsmith + + calls: list[Any] = [] + monkeypatch.setattr( + langsmith, "set_runtime_overrides", lambda **kw: calls.append(kw) + ) + mod.install_aio_to_thread_override() + mod.install_aio_to_thread_override() + mod.install_aio_to_thread_override() + assert len(calls) == 1 + assert calls[0] == {"aio_to_thread": mod._temporal_aio_to_thread} + + +def test_failure_leaves_flag_unset_for_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import langsmith + + boom = RuntimeError("boom") + + def _raise(**_kw: Any) -> None: + raise boom + + monkeypatch.setattr(langsmith, "set_runtime_overrides", _raise) + with pytest.raises(RuntimeError): + mod.install_aio_to_thread_override() + assert mod._installed is False + + # A later call retries and succeeds. + calls: list[Any] = [] + monkeypatch.setattr( + langsmith, "set_runtime_overrides", lambda **kw: calls.append(kw) + ) + mod.install_aio_to_thread_override() + assert len(calls) == 1 + assert mod._installed is True + + +def test_override_delegates_outside_workflows() -> None: + """Outside a workflow the default thread hop is used unchanged.""" + + async def default(_ctx: Any, func: Any, *args: Any, **kwargs: Any) -> Any: + return ("default", func(*args, **kwargs)) + + result = asyncio.run( + mod._temporal_aio_to_thread(default, object(), lambda x: x + 1, 41) + ) + assert result == ("default", 42) From b7f78e514ef1057d16c2b8070eb6d64a5ac77e09 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 15:24:17 -0500 Subject: [PATCH 10/16] _langchain: shared continue-as-new result cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The langgraph and deepagents plugins each implemented the same ContextVar-backed dict cache for carrying completed results across continue-as-new, with different key derivations. Move the langgraph mechanism (the released, vetted one) into the shared core as TaskResultCache — one instance per plugin, so the caches never share state — plus its task_id/cache_key helpers verbatim. langgraph's _task_cache module becomes a delegating surface with identical function signatures and semantics (identity-preserving set, same 32-hex keys); deepagents rebuilds its result cache on the shared mechanism, keeping copy-on-set and snapshot semantics at its own layer. deepagents cache keys change format (previously 64-hex over a different composition) — acceptable while unreleased; only an in-flight continue-as-new carrying cache hits across a worker upgrade would notice. --- temporalio/contrib/_langchain/_task_cache.py | 91 +++++++++++++++ temporalio/contrib/deepagents/_serde.py | 32 ++---- temporalio/contrib/langgraph/_task_cache.py | 69 +++-------- .../langchain_shared/test_task_cache.py | 108 ++++++++++++++++++ 4 files changed, 224 insertions(+), 76 deletions(-) create mode 100644 temporalio/contrib/_langchain/_task_cache.py create mode 100644 tests/contrib/langchain_shared/test_task_cache.py diff --git a/temporalio/contrib/_langchain/_task_cache.py b/temporalio/contrib/_langchain/_task_cache.py new file mode 100644 index 000000000..66bd43c4c --- /dev/null +++ b/temporalio/contrib/_langchain/_task_cache.py @@ -0,0 +1,91 @@ +"""Continue-as-new result caching shared by the LangChain-family plugins.""" + +from __future__ import annotations + +from contextvars import ContextVar +from hashlib import sha256 +from json import dumps +from typing import Any + + +class TaskResultCache: + """A workflow-scoped result cache carried across continue-as-new. + + Each plugin owns its own instance, so caches never share state across + plugins. The backing store is a ``ContextVar``, so update / signal / + query handler tasks spawned by the workflow inherit the same cache + automatically. The cache itself is a plain dict that can travel through + ``workflow.continue_as_new()``. + + ``set_cache`` stores the mapping AS GIVEN (identity semantics — the + langgraph plugin exposes the live dict so mutations ride into the next + run); callers that want copy-on-set semantics wrap at their own layer. + """ + + def __init__(self, context_var_name: str) -> None: + """Create the cache with a uniquely named backing ``ContextVar``.""" + self._var: ContextVar[dict[str, Any] | None] = ContextVar( + context_var_name, default=None + ) + + def set_cache(self, cache: dict[str, Any] | None) -> None: + """Set the result cache for the current context (stored as given).""" + self._var.set(cache) + + def get_cache(self) -> dict[str, Any] | None: + """Get the result cache for the current context.""" + return self._var.get() + + def lookup(self, key: str) -> tuple[bool, Any]: + """Return ``(True, value)`` if cached, ``(False, None)`` otherwise.""" + cache = self._var.get() + if cache is not None and key in cache: + return True, cache[key] + return False, None + + def put(self, key: str, value: Any) -> None: + """Store a value when a cache is active for this context.""" + cache = self._var.get() + if cache is not None: + cache[key] = value + + +def task_id(func: Any) -> str: + """Return the fully-qualified module.qualname for a function. + + Raises ValueError for functions that cannot be identified unambiguously + (lambdas, closures, __main__ functions). + """ + module = getattr(func, "__module__", None) + qualname = getattr(func, "__qualname__", None) or getattr(func, "__name__", None) + + if module is None or qualname is None: + raise ValueError( + f"Cannot identify task {func}: missing __module__ or __qualname__. " + "Tasks must be defined at module level." + ) + if module == "__main__": + raise ValueError( + f"Cannot identify task {qualname}: defined in __main__. " + "Tasks must be importable from a named module." + ) + if "" in qualname: + raise ValueError( + f"Cannot identify task {qualname}: closures/local functions are not supported. " + "Tasks must be defined at module level." + ) + return f"{module}.{qualname}" + + +def cache_key( + task_id: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], + context: Any = None, +) -> str: + """Build a cache key from the full task identifier, arguments, and runtime context.""" + try: + key_str = dumps([task_id, args, kwargs, context], sort_keys=True, default=str) + except (TypeError, ValueError): + key_str = repr([task_id, args, kwargs, context]) + return sha256(key_str.encode()).hexdigest()[:32] diff --git a/temporalio/contrib/deepagents/_serde.py b/temporalio/contrib/deepagents/_serde.py index 13ed0d1c6..924b58176 100644 --- a/temporalio/contrib/deepagents/_serde.py +++ b/temporalio/contrib/deepagents/_serde.py @@ -29,15 +29,14 @@ 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._langchain._task_cache import TaskResultCache +from temporalio.contrib._langchain._task_cache import cache_key as _shared_cache_key from temporalio.contrib.pydantic import PydanticPayloadConverter, ToJsonOptions from temporalio.converter import DataConverter @@ -328,44 +327,37 @@ def rebuild_runnable_config(data: dict[str, Any]) -> "RunnableConfig": # --------------------------------------------------------------------------- # 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 -) +# tool dispatch paths, snapshotted for continue-as-new. Built on the shared +# ContextVar-backed cache (update / signal handler tasks spawned by the +# workflow inherit it automatically); this plugin's instance is distinct from +# the langgraph plugin's. +_result_cache = TaskResultCache("_deepagents_result_cache") 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 {}) + _result_cache.set_cache(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() + cache = _result_cache.get_cache() 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() + return _shared_cache_key(kind, (call_id, args), {}) 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 + return _result_cache.lookup(key) 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 + _result_cache.put(key, value) # --------------------------------------------------------------------------- diff --git a/temporalio/contrib/langgraph/_task_cache.py b/temporalio/contrib/langgraph/_task_cache.py index ab3e683d7..f6c046c3c 100644 --- a/temporalio/contrib/langgraph/_task_cache.py +++ b/temporalio/contrib/langgraph/_task_cache.py @@ -3,81 +3,38 @@ Caches task results by (module.qualname, args, kwargs) hash so that previously completed tasks are not re-executed after a continue-as-new. The cache state is a plain dict that can travel through workflow.continue_as_new(). + +The mechanism lives in ``temporalio.contrib._langchain._task_cache``; this +module binds the langgraph plugin's own cache instance and preserves the +original function surface. """ from __future__ import annotations -from contextvars import ContextVar -from hashlib import sha256 -from json import dumps from typing import Any -_task_cache: ContextVar[dict[str, Any] | None] = ContextVar( - "_temporal_task_cache", default=None -) +from temporalio.contrib._langchain._task_cache import TaskResultCache +from temporalio.contrib._langchain._task_cache import cache_key as cache_key +from temporalio.contrib._langchain._task_cache import task_id as task_id + +_cache = TaskResultCache("_temporal_task_cache") def set_task_cache(cache: dict[str, Any] | None) -> None: """Set the task result cache for the current context.""" - _task_cache.set(cache) + _cache.set_cache(cache) def get_task_cache() -> dict[str, Any] | None: """Get the task result cache for the current context.""" - return _task_cache.get() - - -def task_id(func: Any) -> str: - """Return the fully-qualified module.qualname for a function. - - Raises ValueError for functions that cannot be identified unambiguously - (lambdas, closures, __main__ functions). - """ - module = getattr(func, "__module__", None) - qualname = getattr(func, "__qualname__", None) or getattr(func, "__name__", None) - - if module is None or qualname is None: - raise ValueError( - f"Cannot identify task {func}: missing __module__ or __qualname__. " - "Tasks must be defined at module level." - ) - if module == "__main__": - raise ValueError( - f"Cannot identify task {qualname}: defined in __main__. " - "Tasks must be importable from a named module." - ) - if "" in qualname: - raise ValueError( - f"Cannot identify task {qualname}: closures/local functions are not supported. " - "Tasks must be defined at module level." - ) - return f"{module}.{qualname}" - - -def cache_key( - task_id: str, - args: tuple[Any, ...], - kwargs: dict[str, Any], - context: Any = None, -) -> str: - """Build a cache key from the full task identifier, arguments, and runtime context.""" - try: - key_str = dumps([task_id, args, kwargs, context], sort_keys=True, default=str) - except (TypeError, ValueError): - key_str = repr([task_id, args, kwargs, context]) - return sha256(key_str.encode()).hexdigest()[:32] + return _cache.get_cache() def cache_lookup(key: str) -> tuple[bool, Any]: """Return (True, value) if cached, (False, None) otherwise.""" - cache = _task_cache.get() - if cache is not None and key in cache: - return True, cache[key] - return False, None + return _cache.lookup(key) def cache_put(key: str, value: Any) -> None: """Store a value in the task result cache.""" - cache = _task_cache.get() - if cache is not None: - cache[key] = value + _cache.put(key, value) diff --git a/tests/contrib/langchain_shared/test_task_cache.py b/tests/contrib/langchain_shared/test_task_cache.py new file mode 100644 index 000000000..4638641ba --- /dev/null +++ b/tests/contrib/langchain_shared/test_task_cache.py @@ -0,0 +1,108 @@ +"""The shared continue-as-new result cache.""" + +from __future__ import annotations + +import functools +from contextvars import copy_context + +import pytest + +from temporalio.contrib._langchain._task_cache import ( + TaskResultCache, + cache_key, + task_id, +) + + +def test_instances_do_not_share_state() -> None: + a = TaskResultCache("test_cache_a") + b = TaskResultCache("test_cache_b") + a.set_cache({}) + a.put("k", "va") + assert a.lookup("k") == (True, "va") + # b has no active cache at all — a's state is invisible to it. + assert b.lookup("k") == (False, None) + b.set_cache({}) + assert b.lookup("k") == (False, None) + + +def test_isolation_across_copied_contexts() -> None: + cache = TaskResultCache("test_cache_ctx") + + def seed() -> None: + cache.set_cache({"k": "inner"}) + + # Setting inside a copied context must not leak to the outer context. + copy_context().run(seed) + assert cache.get_cache() is None + + +def test_set_cache_keeps_identity() -> None: + """The langgraph plugin exposes the live dict; the core must not copy.""" + cache = TaskResultCache("test_cache_identity") + backing: dict[str, object] = {} + cache.set_cache(backing) + assert cache.get_cache() is backing + cache.put("k", 1) + assert backing == {"k": 1} + + +def test_put_without_active_cache_is_a_noop() -> None: + cache = TaskResultCache("test_cache_noop") + cache.put("k", 1) + assert cache.lookup("k") == (False, None) + + +def test_task_id_rejects_unidentifiable_functions() -> None: + def outer(): + def inner() -> None: ... + + return inner + + with pytest.raises(ValueError, match="closures"): + task_id(outer()) + + class _MainStub: + __module__ = "__main__" + __qualname__ = "stub" + + with pytest.raises(ValueError, match="__main__"): + task_id(_MainStub()) + + # functools.partial instances carry neither __qualname__ nor __name__. + with pytest.raises(ValueError, match="module level"): + task_id(functools.partial(print)) + + +def test_task_id_happy_path() -> None: + assert task_id(test_task_id_happy_path) == f"{__name__}.test_task_id_happy_path" + + +def test_cache_key_stable_and_fallback() -> None: + k1 = cache_key("mod.fn", (1, "a"), {"b": 2}, context=None) + k2 = cache_key("mod.fn", (1, "a"), {"b": 2}, context=None) + assert k1 == k2 and len(k1) == 32 + # Different inputs, different key. + assert cache_key("mod.fn", (2, "a"), {"b": 2}) != k1 + + # Unserializable arguments fall back to repr and still produce a key. + class Weird: + def __repr__(self) -> str: + return "" + + k3 = cache_key("mod.fn", (Weird(),), {}) + assert len(k3) == 32 + + +def test_langgraph_delegation_preserves_identity_semantics() -> None: + pytest.importorskip("langgraph") + from temporalio.contrib.langgraph import _task_cache as lg + + backing: dict[str, object] = {"seed": 1} + lg.set_task_cache(backing) + assert lg.get_task_cache() is backing + lg.cache_put("k", "v") + assert lg.cache_lookup("k") == (True, "v") + assert backing["k"] == "v" + lg.set_task_cache(None) + assert lg.get_task_cache() is None From 1e88e8e9ccbaeb91cb24aff719dd35fc84606dd8 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 15:32:04 -0500 Subject: [PATCH 11/16] _langchain: parameterized RunnableConfig strip/rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit langgraph and deepagents each stripped RunnableConfig for the activity boundary with deliberately different selection rules: langgraph whitelists its seven checkpoint/resumption configurable keys and passes metadata through untouched; deepagents keeps any JSON-safe non-dunder configurable entry and filters metadata values. Move the mechanism to the shared core with langgraph's released output shape as the base (tags/metadata always present, truthy run_name/run_id, whitelist iterated in whitelist order) and the selection rules as parameters. langgraph's strip becomes a thin wrapper over its whitelist — a byte-identity test embeds the pre-refactor implementation verbatim and string-compares JSON dumps plus derived continue-as-new cache keys across a corpus, so released payloads and carried caches are pinned. set_langgraph_config now shares the whitelist constant instead of duplicating the tuple. deepagents adopts the shared shape (accepted deltas on an unreleased plugin: always-present tags/metadata, truthy gates, no str() coercion of run ids) and re-exports the shared rebuild/is_jsonish under its existing names. --- .../contrib/_langchain/_runnable_config.py | 98 +++++++++ temporalio/contrib/deepagents/_serde.py | 83 +++----- .../contrib/langgraph/_langgraph_config.py | 76 +++---- .../langchain_shared/test_runnable_config.py | 195 ++++++++++++++++++ 4 files changed, 353 insertions(+), 99 deletions(-) create mode 100644 temporalio/contrib/_langchain/_runnable_config.py create mode 100644 tests/contrib/langchain_shared/test_runnable_config.py diff --git a/temporalio/contrib/_langchain/_runnable_config.py b/temporalio/contrib/_langchain/_runnable_config.py new file mode 100644 index 000000000..17d78825b --- /dev/null +++ b/temporalio/contrib/_langchain/_runnable_config.py @@ -0,0 +1,98 @@ +"""``RunnableConfig`` strip/rebuild shared by the LangChain-family plugins.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from langchain_core.runnables import RunnableConfig + + +def is_jsonish(value: Any) -> bool: + """Return True for values that survive a plain JSON round-trip unchanged.""" + 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: Mapping[str, Any] | None, + *, + configurable_keys: Sequence[str] | None = None, + configurable_filter: Callable[[str, Any], bool] | None = None, + metadata_filter: Callable[[Any], bool] | None = None, +) -> dict[str, Any]: + """Return a serializable subset of a ``RunnableConfig``. + + The full object holds non-serializable things (callbacks, checkpointer / + store / cache handles, pregel send/read callables) that cannot cross an + activity boundary, so only primitive fields and a caller-selected subset + of ``configurable`` are kept. The output shape follows the langgraph + plugin's released behavior: ``tags`` and ``metadata`` are always present + (empty when absent), ``run_name`` / ``run_id`` are kept when truthy, + ``recursion_limit`` when not None, and ``configurable`` only when the + selection is non-empty. + + Exactly one of ``configurable_keys`` (keys kept in whitelist order) or + ``configurable_filter`` (entries kept in source order) must be provided. + ``metadata_filter`` optionally drops metadata VALUES (default: keep all, + matching langgraph). + """ + if (configurable_keys is None) == (configurable_filter is None): + raise ValueError( + "exactly one of configurable_keys or configurable_filter is required" + ) + orig: Mapping[str, Any] = config or {} + configurable: Mapping[str, Any] = orig.get("configurable") or {} + + metadata = dict(orig.get("metadata") or {}) + if metadata_filter is not None: + metadata = {k: v for k, v in metadata.items() if metadata_filter(v)} + result: dict[str, Any] = { + "tags": list(orig.get("tags") or []), + "metadata": metadata, + } + if run_name := orig.get("run_name"): + result["run_name"] = run_name + if run_id := orig.get("run_id"): + result["run_id"] = run_id + if (recursion_limit := orig.get("recursion_limit")) is not None: + result["recursion_limit"] = recursion_limit + + if configurable_keys is not None: + stripped_configurable: dict[str, Any] = { + key: configurable[key] for key in configurable_keys if key in configurable + } + else: + stripped_configurable = { + k: v + for k, v in configurable.items() + # The guard above makes configurable_filter non-None on this + # branch; the re-check narrows for type checkers. + if configurable_filter is not None and configurable_filter(k, v) + } + if stripped_configurable: + result["configurable"] = stripped_configurable + return result + + +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)) diff --git a/temporalio/contrib/deepagents/_serde.py b/temporalio/contrib/deepagents/_serde.py index 924b58176..f249ddd41 100644 --- a/temporalio/contrib/deepagents/_serde.py +++ b/temporalio/contrib/deepagents/_serde.py @@ -30,11 +30,15 @@ from __future__ import annotations import dataclasses -from typing import TYPE_CHECKING, Any, cast - -if TYPE_CHECKING: - from langchain_core.runnables import RunnableConfig +from typing import Any +from temporalio.contrib._langchain._runnable_config import is_jsonish +from temporalio.contrib._langchain._runnable_config import ( + rebuild_runnable_config as rebuild_runnable_config, +) +from temporalio.contrib._langchain._runnable_config import ( + strip_runnable_config as _shared_strip_runnable_config, +) from temporalio.contrib._langchain._task_cache import TaskResultCache from temporalio.contrib._langchain._task_cache import cache_key as _shared_cache_key from temporalio.contrib.pydantic import PydanticPayloadConverter, ToJsonOptions @@ -262,64 +266,31 @@ def tool_to_schema(tool: Any) -> dict[str, Any]: # --------------------------------------------------------------------------- -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 +# Shared with the other LangChain-family plugins; kept under the old private +# name because sibling modules reference it. +_is_jsonish = is_jsonish + + +def _keep_configurable(key: str, value: Any) -> bool: + """Keep non-dunder, JSON-safe configurable entries.""" + return not key.startswith("__") and is_jsonish(value) 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. + Keeps ``tags`` and ``metadata`` (always present, values filtered to + JSON-safe ones), truthy ``run_name`` / ``run_id``, ``recursion_limit``, + and the JSON-safe non-dunder ``configurable`` keys. Drops callbacks, + checkpointer / store / cache handles and every other live reference — + those are reconstructed activity-side. Output shape follows the shared + (langgraph-vetted) implementation. """ - 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)) + return _shared_strip_runnable_config( + config, + configurable_filter=_keep_configurable, + metadata_filter=is_jsonish, + ) # --------------------------------------------------------------------------- diff --git a/temporalio/contrib/langgraph/_langgraph_config.py b/temporalio/contrib/langgraph/_langgraph_config.py index 90c6c810d..baa1084ec 100644 --- a/temporalio/contrib/langgraph/_langgraph_config.py +++ b/temporalio/contrib/langgraph/_langgraph_config.py @@ -3,7 +3,7 @@ # pyright: reportMissingTypeStubs=false import dataclasses -from typing import Any, Callable +from typing import Any, Callable, cast from langchain_core.runnables.config import var_child_runnable_config from langgraph._internal._constants import ( @@ -23,6 +23,23 @@ from langgraph.pregel._algo import LazyAtomicCounter from langgraph.runtime import ExecutionInfo, Runtime +from temporalio.contrib._langchain._runnable_config import ( + strip_runnable_config as _shared_strip_runnable_config, +) + +# The configurable keys the langgraph plugin ships across activity +# boundaries (checkpoint/resumption state); everything else in +# ``configurable`` is a live handle that stays behind. +_KEPT_CONFIGURABLE_KEYS = ( + CONFIG_KEY_CHECKPOINT_NS, + CONFIG_KEY_CHECKPOINT_ID, + CONFIG_KEY_CHECKPOINT_MAP, + CONFIG_KEY_THREAD_ID, + CONFIG_KEY_TASK_ID, + CONFIG_KEY_RESUMING, + CONFIG_KEY_DURABILITY, +) + def strip_runnable_config(config: RunnableConfig | None) -> RunnableConfig: """Return a serializable subset of a RunnableConfig. @@ -31,38 +48,21 @@ def strip_runnable_config(config: RunnableConfig | None) -> RunnableConfig: config kwarg. The full object holds non-serializable things (callbacks, checkpointer/store/cache handles, pregel send/read callables) that can't cross an activity boundary, so we keep only primitive fields and the - serializable subset of configurable. + serializable subset of configurable. Delegates to the shared + implementation with this plugin's checkpoint-key whitelist; the output is + byte-identical to the pre-refactor behavior. """ - orig = config or {} - configurable = orig.get("configurable") or {} - - result: RunnableConfig = { - "tags": list(orig.get("tags") or []), - "metadata": dict(orig.get("metadata") or {}), - } - if run_name := orig.get("run_name"): - result["run_name"] = run_name - if run_id := orig.get("run_id"): - result["run_id"] = run_id - if (recursion_limit := orig.get("recursion_limit")) is not None: - result["recursion_limit"] = recursion_limit - - stripped_configurable: dict[str, Any] = { - key: configurable[key] - for key in ( - CONFIG_KEY_CHECKPOINT_NS, - CONFIG_KEY_CHECKPOINT_ID, - CONFIG_KEY_CHECKPOINT_MAP, - CONFIG_KEY_THREAD_ID, - CONFIG_KEY_TASK_ID, - CONFIG_KEY_RESUMING, - CONFIG_KEY_DURABILITY, - ) - if key in configurable - } - if stripped_configurable: - result["configurable"] = stripped_configurable - return result + # Double cast: a TypedDict and dict[str, Any] "insufficiently overlap" + # for basedpyright's reportInvalidCast; object is the sanctioned bridge. + return cast( + "RunnableConfig", + cast( + object, + _shared_strip_runnable_config( + config, configurable_keys=_KEPT_CONFIGURABLE_KEYS + ), + ), + ) def get_langgraph_config() -> dict[str, Any]: @@ -124,17 +124,7 @@ def get_null_resume(consume: bool = False) -> Any: ) restored_configurable: dict[str, Any] = { - key: configurable[key] - for key in ( - CONFIG_KEY_CHECKPOINT_NS, - CONFIG_KEY_CHECKPOINT_ID, - CONFIG_KEY_CHECKPOINT_MAP, - CONFIG_KEY_THREAD_ID, - CONFIG_KEY_TASK_ID, - CONFIG_KEY_RESUMING, - CONFIG_KEY_DURABILITY, - ) - if key in configurable + key: configurable[key] for key in _KEPT_CONFIGURABLE_KEYS if key in configurable } restored_configurable[CONFIG_KEY_SCRATCHPAD] = PregelScratchpad( step=scratchpad.get("step", 0), diff --git a/tests/contrib/langchain_shared/test_runnable_config.py b/tests/contrib/langchain_shared/test_runnable_config.py new file mode 100644 index 000000000..a58cd6275 --- /dev/null +++ b/tests/contrib/langchain_shared/test_runnable_config.py @@ -0,0 +1,195 @@ +"""The shared ``RunnableConfig`` strip/rebuild. + +The langgraph plugin's strip output is RELEASED behavior: stripped configs +ride in activity payloads and feed continue-as-new cache keys, so the shared +implementation must reproduce it byte-for-byte. ``_reference_strip_pre_refactor`` +below is the pre-refactor langgraph implementation embedded verbatim; the +corpus test string-compares JSON dumps (captures key order, not just dict +equality) and compares derived cache keys. +""" + +from __future__ import annotations + +import json +import uuid +from typing import Any + +import pytest + +from temporalio.contrib._langchain._runnable_config import ( + is_jsonish, + rebuild_runnable_config, + strip_runnable_config, +) +from temporalio.contrib._langchain._task_cache import cache_key + +pytest.importorskip("langgraph") + +from langgraph._internal._constants import ( # noqa: E402 # pyright: ignore[reportMissingTypeStubs] + CONFIG_KEY_CHECKPOINT_ID, + CONFIG_KEY_CHECKPOINT_MAP, + CONFIG_KEY_CHECKPOINT_NS, + CONFIG_KEY_DURABILITY, + CONFIG_KEY_RESUMING, + CONFIG_KEY_TASK_ID, + CONFIG_KEY_THREAD_ID, +) + +_KEPT = ( + CONFIG_KEY_CHECKPOINT_NS, + CONFIG_KEY_CHECKPOINT_ID, + CONFIG_KEY_CHECKPOINT_MAP, + CONFIG_KEY_THREAD_ID, + CONFIG_KEY_TASK_ID, + CONFIG_KEY_RESUMING, + CONFIG_KEY_DURABILITY, +) + + +def _reference_strip_pre_refactor(config: Any) -> dict[str, Any]: + """The langgraph plugin's strip_runnable_config as released (verbatim).""" + orig = config or {} + configurable = orig.get("configurable") or {} + + result: dict[str, Any] = { + "tags": list(orig.get("tags") or []), + "metadata": dict(orig.get("metadata") or {}), + } + if run_name := orig.get("run_name"): + result["run_name"] = run_name + if run_id := orig.get("run_id"): + result["run_id"] = run_id + if (recursion_limit := orig.get("recursion_limit")) is not None: + result["recursion_limit"] = recursion_limit + + stripped_configurable: dict[str, Any] = { + key: configurable[key] + for key in ( + CONFIG_KEY_CHECKPOINT_NS, + CONFIG_KEY_CHECKPOINT_ID, + CONFIG_KEY_CHECKPOINT_MAP, + CONFIG_KEY_THREAD_ID, + CONFIG_KEY_TASK_ID, + CONFIG_KEY_RESUMING, + CONFIG_KEY_DURABILITY, + ) + if key in configurable + } + if stripped_configurable: + result["configurable"] = stripped_configurable + return result + + +class _LiveJunk: + """Stand-in for callbacks / checkpointer handles / pregel callables.""" + + def __repr__(self) -> str: + return "" + + +def _corpus() -> list[Any]: + junk = _LiveJunk() + scrambled_configurable = { + "user_key": "kept-by-nothing", + CONFIG_KEY_DURABILITY: "sync", + "__pregel_send": junk, + CONFIG_KEY_THREAD_ID: "t-1", + CONFIG_KEY_CHECKPOINT_NS: "ns", + "another": {"nested": True}, + CONFIG_KEY_RESUMING: False, + CONFIG_KEY_CHECKPOINT_MAP: {"a": "b"}, + CONFIG_KEY_TASK_ID: "task-9", + CONFIG_KEY_CHECKPOINT_ID: "ckpt-3", + } + return [ + None, + {}, + {"tags": [], "metadata": {}}, + {"tags": ["a", "b"], "metadata": {"k": "v", "obj": junk}}, + {"run_name": ""}, # falsy run_name: dropped + {"run_name": "r", "run_id": uuid.uuid4()}, + {"recursion_limit": 0}, # zero is kept (not-None gate) + {"recursion_limit": None}, + { + "tags": ["x"], + "metadata": {"m": 1}, + "run_name": "full", + "run_id": "rid", + "recursion_limit": 25, + "callbacks": junk, + "configurable": scrambled_configurable, + }, + {"configurable": {k: f"v-{k}" for k in reversed(_KEPT)}}, + {"configurable": {"only": "dropped keys"}}, + ] + + +def test_langgraph_shape_is_byte_identical_to_released_behavior() -> None: + for cfg in _corpus(): + expected = _reference_strip_pre_refactor(cfg) + actual = strip_runnable_config(cfg, configurable_keys=_KEPT) + assert json.dumps(actual, default=str) == json.dumps(expected, default=str), cfg + # Cache keys derived from stripped configs must not shift either. + assert cache_key("t", (), {"config": actual}) == cache_key( + "t", (), {"config": expected} + ) + + +def test_langgraph_plugin_wrapper_matches_reference() -> None: + from temporalio.contrib.langgraph._langgraph_config import ( + strip_runnable_config as lg_strip, + ) + + for cfg in _corpus(): + assert json.dumps(lg_strip(cfg), default=str) == json.dumps( + _reference_strip_pre_refactor(cfg), default=str + ) + + +def test_filter_mode_matches_deepagents_semantics() -> None: + junk = _LiveJunk() + cfg = { + "tags": ["t"], + "metadata": {"ok": 1, "bad": junk}, + "run_id": "rid", + "configurable": {"keep": "v", "__dunder": "x", "unsafe": junk}, + } + out = strip_runnable_config( + cfg, + configurable_filter=lambda k, v: not k.startswith("__") and is_jsonish(v), + metadata_filter=is_jsonish, + ) + assert out == { + "tags": ["t"], + "metadata": {"ok": 1}, + "run_id": "rid", + "configurable": {"keep": "v"}, + } + + +def test_rebuild_round_trip() -> None: + stripped = { + "tags": ["t"], + "metadata": {"m": 1}, + "run_id": "rid", + "recursion_limit": 5, + "configurable": {"k": "v"}, + } + assert rebuild_runnable_config(dict(stripped)) == { + "metadata": {"m": 1}, + "tags": ["t"], + "run_id": "rid", + "recursion_limit": 5, + "configurable": {"k": "v"}, + } + # Empty input still yields the always-present metadata mapping. + assert rebuild_runnable_config({}) == {"metadata": {}} + + +def test_exactly_one_configurable_selector_required() -> None: + with pytest.raises(ValueError, match="exactly one"): + strip_runnable_config({}) + with pytest.raises(ValueError, match="exactly one"): + strip_runnable_config( + {}, configurable_keys=("a",), configurable_filter=lambda k, v: True + ) From c2abe4bb7977a331330b7a5cd7404dd86939d71d Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 15:36:54 -0500 Subject: [PATCH 12/16] _langchain: shared message serde, converter, activity helpers, passthrough merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the remaining family-generic machinery out of the deepagents plugin into the shared core: langchain_core dumpd/load message and object serialization plus the OpenAI tool-schema advertisement; the exclude_unset Pydantic payload converter and its compose-or-refuse data-converter builder (error text now names the refusing plugin); the activity auto-heartbeater and duck-typed LLM HTTP error translation; and the order-preserving passthrough-list merge (mechanism only — each plugin keeps its own module list, since the lists are released behavior for langgraph/langsmith). deepagents re-exports everything under its existing names, so its sibling modules and tests are unchanged. The converter module is the one core module with an eager contrib.pydantic import; it is only imported by plugins that wire the converter, and the core __init__ stays import-free. --- .../contrib/_langchain/_activity_helpers.py | 90 +++++++++++++++ temporalio/contrib/_langchain/_converter.py | 61 ++++++++++ temporalio/contrib/_langchain/_messages.py | 44 +++++++ temporalio/contrib/_langchain/_passthrough.py | 22 ++++ temporalio/contrib/deepagents/_activity.py | 91 +-------------- temporalio/contrib/deepagents/_serde.py | 108 +++++------------- tests/contrib/deepagents/test_failures.py | 4 +- .../langchain_shared/test_converter.py | 59 ++++++++++ .../langchain_shared/test_passthrough.py | 15 +++ .../langchain_shared/test_serde_messages.py | 58 ++++++++++ 10 files changed, 389 insertions(+), 163 deletions(-) create mode 100644 temporalio/contrib/_langchain/_activity_helpers.py create mode 100644 temporalio/contrib/_langchain/_converter.py create mode 100644 temporalio/contrib/_langchain/_messages.py create mode 100644 temporalio/contrib/_langchain/_passthrough.py create mode 100644 tests/contrib/langchain_shared/test_converter.py create mode 100644 tests/contrib/langchain_shared/test_passthrough.py create mode 100644 tests/contrib/langchain_shared/test_serde_messages.py diff --git a/temporalio/contrib/_langchain/_activity_helpers.py b/temporalio/contrib/_langchain/_activity_helpers.py new file mode 100644 index 000000000..15551f819 --- /dev/null +++ b/temporalio/contrib/_langchain/_activity_helpers.py @@ -0,0 +1,90 @@ +"""Activity-side helpers shared by the LangChain-family plugins.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from functools import wraps +from typing import Any, Callable + +from temporalio import activity +from temporalio.exceptions import ApplicationError + + +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() + # 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 + + +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, + ) diff --git a/temporalio/contrib/_langchain/_converter.py b/temporalio/contrib/_langchain/_converter.py new file mode 100644 index 000000000..e5fc39756 --- /dev/null +++ b/temporalio/contrib/_langchain/_converter.py @@ -0,0 +1,61 @@ +"""Payload converter shared by the LangChain-family plugins. + +This module eagerly imports ``temporalio.contrib.pydantic``; only plugins +that actually wire the converter import it, so the core package itself stays +importable without pydantic installed. +""" + +from __future__ import annotations + +import dataclasses + +from temporalio.contrib.pydantic import PydanticPayloadConverter, ToJsonOptions +from temporalio.converter import DataConverter + + +class LangChainPayloadConverter(PydanticPayloadConverter): + """Pydantic payload converter pinned to ``exclude_unset=True``. + + LangChain request/response types 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 unset fields are excluded 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=LangChainPayloadConverter) +"""The family 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, + *, + plugin_name: str, +) -> DataConverter: + """Compose the family converter with whatever the caller already set. + + * ``None`` — install the family 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 ``LangChainPayloadConverter`` into their own converter. + ``plugin_name`` names the refusing plugin in the error. + """ + if user_converter is None: + return data_converter + if user_converter is DataConverter.default: + return dataclasses.replace( + user_converter, payload_converter_class=LangChainPayloadConverter + ) + raise ValueError( + f"{plugin_name} cannot compose with a custom data_converter " + "automatically. Set payload_converter_class=LangChainPayloadConverter " + "on your own DataConverter (so LangChain messages serialize with " + "exclude_unset=True), or omit data_converter to use the plugin default." + ) diff --git a/temporalio/contrib/_langchain/_messages.py b/temporalio/contrib/_langchain/_messages.py new file mode 100644 index 000000000..946f9383e --- /dev/null +++ b/temporalio/contrib/_langchain/_messages.py @@ -0,0 +1,44 @@ +"""LangChain object serialization shared by the LangChain-family plugins.""" + +from __future__ import annotations + +from typing import Any + + +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) + + +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) diff --git a/temporalio/contrib/_langchain/_passthrough.py b/temporalio/contrib/_langchain/_passthrough.py new file mode 100644 index 000000000..8979683b8 --- /dev/null +++ b/temporalio/contrib/_langchain/_passthrough.py @@ -0,0 +1,22 @@ +"""Sandbox passthrough-list merging shared by the LangChain-family plugins. + +Only the MECHANISM lives here; each plugin owns its module list (the lists +are behavior for released plugins and must not drift by sharing). +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence + + +def merge_passthrough_modules( + defaults: Sequence[str], user: Iterable[str] | None +) -> tuple[str, ...]: + """Merge caller-supplied passthrough modules with a plugin's defaults. + + Order-preserving: defaults first, then user additions, first occurrence + wins on duplicates. + """ + merged = [*defaults, *(user or ())] + # dict.fromkeys preserves order while de-duplicating. + return tuple(dict.fromkeys(merged)) diff --git a/temporalio/contrib/deepagents/_activity.py b/temporalio/contrib/deepagents/_activity.py index 3c1501a42..066524845 100644 --- a/temporalio/contrib/deepagents/_activity.py +++ b/temporalio/contrib/deepagents/_activity.py @@ -27,10 +27,15 @@ import dataclasses import importlib from datetime import timedelta -from functools import wraps from typing import Any, Callable from temporalio import activity +from temporalio.contrib._langchain._activity_helpers import ( + auto_heartbeater as _auto_heartbeater, +) +from temporalio.contrib._langchain._activity_helpers import ( + translate_api_error as _translate_api_error, +) from temporalio.contrib.deepagents import _serde from temporalio.exceptions import ApplicationError @@ -124,90 +129,6 @@ class BackendOpOutput: # --------------------------------------------------------------------------- -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() - # 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 - - -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. diff --git a/temporalio/contrib/deepagents/_serde.py b/temporalio/contrib/deepagents/_serde.py index f249ddd41..0af2060df 100644 --- a/temporalio/contrib/deepagents/_serde.py +++ b/temporalio/contrib/deepagents/_serde.py @@ -32,6 +32,31 @@ import dataclasses from typing import Any +from temporalio.contrib._langchain._converter import ( + LangChainPayloadConverter, +) +from temporalio.contrib._langchain._converter import ( + build_data_converter as _shared_build_data_converter, +) +from temporalio.contrib._langchain._converter import ( + data_converter as data_converter, +) +from temporalio.contrib._langchain._messages import ( + dump_messages as dump_messages, +) +from temporalio.contrib._langchain._messages import ( + dump_object as dump_object, +) +from temporalio.contrib._langchain._messages import ( + load_messages as load_messages, +) +from temporalio.contrib._langchain._messages import ( + load_object as load_object, +) +from temporalio.contrib._langchain._messages import ( + tool_to_schema as tool_to_schema, +) +from temporalio.contrib._langchain._passthrough import merge_passthrough_modules from temporalio.contrib._langchain._runnable_config import is_jsonish from temporalio.contrib._langchain._runnable_config import ( rebuild_runnable_config as rebuild_runnable_config, @@ -41,7 +66,6 @@ ) from temporalio.contrib._langchain._task_cache import TaskResultCache from temporalio.contrib._langchain._task_cache import cache_key as _shared_cache_key -from temporalio.contrib.pydantic import PydanticPayloadConverter, ToJsonOptions from temporalio.converter import DataConverter # --------------------------------------------------------------------------- @@ -88,23 +112,8 @@ def get_settings() -> Settings: # --------------------------------------------------------------------------- -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).""" +# The family converter, kept under this plugin's historical name. +DeepAgentsPayloadConverter = LangChainPayloadConverter def build_data_converter( @@ -112,24 +121,10 @@ def build_data_converter( ) -> 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. + Delegates to the shared family implementation; see its docstring for the + None / SDK-default / custom-converter contract. """ - 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." - ) + return _shared_build_data_converter(user_converter, plugin_name="DeepAgentsPlugin") # --------------------------------------------------------------------------- @@ -137,20 +132,6 @@ def build_data_converter( # --------------------------------------------------------------------------- -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 # --------------------------------------------------------------------------- @@ -236,31 +217,6 @@ def load_backend_result(value: Any) -> Any: 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 # --------------------------------------------------------------------------- @@ -368,6 +324,4 @@ def default_passthrough_modules() -> tuple[str, ...]: 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)) + return merge_passthrough_modules(_DEFAULT_PASSTHROUGH, user) diff --git a/tests/contrib/deepagents/test_failures.py b/tests/contrib/deepagents/test_failures.py index 6cfe6e5ff..21cf5de52 100644 --- a/tests/contrib/deepagents/test_failures.py +++ b/tests/contrib/deepagents/test_failures.py @@ -22,8 +22,10 @@ ) from temporalio import workflow from temporalio.client import WorkflowFailureError +from temporalio.contrib._langchain._activity_helpers import ( + translate_api_error as _translate_api_error, +) 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 diff --git a/tests/contrib/langchain_shared/test_converter.py b/tests/contrib/langchain_shared/test_converter.py new file mode 100644 index 000000000..595735ded --- /dev/null +++ b/tests/contrib/langchain_shared/test_converter.py @@ -0,0 +1,59 @@ +"""The shared LangChain-family payload converter composition.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from temporalio.converter import DataConverter + +pytest.importorskip("pydantic") + +from temporalio.contrib._langchain._converter import ( # noqa: E402 + LangChainPayloadConverter, + build_data_converter, + data_converter, +) + + +def test_none_installs_family_default() -> None: + assert build_data_converter(None, plugin_name="XPlugin") is data_converter + + +def test_sdk_default_gets_converter_swapped() -> None: + result = build_data_converter(DataConverter.default, plugin_name="XPlugin") + assert result.payload_converter_class is LangChainPayloadConverter + # Everything else on the SDK default is preserved (dataclasses.replace + # rebuilds derived instance attributes, so compare the declared fields). + assert ( + result.failure_converter_class is DataConverter.default.failure_converter_class + ) + assert result.payload_codec is DataConverter.default.payload_codec + + +def test_custom_converter_is_refused_with_plugin_name() -> None: + custom = dataclasses.replace(DataConverter.default) + with pytest.raises(ValueError, match="XPlugin cannot compose"): + build_data_converter(custom, plugin_name="XPlugin") + + +def test_exclude_unset_round_trip() -> None: + from pydantic import BaseModel + + class Inner(BaseModel): + a: int = 1 + b: str | None = None + + class Outer(BaseModel): + inner: Inner + note: str | None = None + + converter = LangChainPayloadConverter() + payload = converter.to_payload(Outer(inner=Inner(a=2))) + assert payload is not None + # Unset defaults are excluded from the wire form. + assert b'"b"' not in payload.data + assert b'"note"' not in payload.data + restored = converter.from_payload(payload, Outer) + assert restored.inner.a == 2 and restored.note is None diff --git a/tests/contrib/langchain_shared/test_passthrough.py b/tests/contrib/langchain_shared/test_passthrough.py new file mode 100644 index 000000000..1844934a4 --- /dev/null +++ b/tests/contrib/langchain_shared/test_passthrough.py @@ -0,0 +1,15 @@ +"""The shared sandbox passthrough-list merge.""" + +from __future__ import annotations + +from temporalio.contrib._langchain._passthrough import merge_passthrough_modules + + +def test_merge_dedupes_preserving_first_occurrence_order() -> None: + merged = merge_passthrough_modules(("a", "b", "c"), ["b", "d", "a", "e"]) + assert merged == ("a", "b", "c", "d", "e") + + +def test_merge_tolerates_no_user_list() -> None: + assert merge_passthrough_modules(("a", "b"), None) == ("a", "b") + assert merge_passthrough_modules((), None) == () diff --git a/tests/contrib/langchain_shared/test_serde_messages.py b/tests/contrib/langchain_shared/test_serde_messages.py new file mode 100644 index 000000000..f47f12e7e --- /dev/null +++ b/tests/contrib/langchain_shared/test_serde_messages.py @@ -0,0 +1,58 @@ +"""Message and tool-schema serialization in the shared core.""" + +from __future__ import annotations + +import pytest + +from temporalio.contrib._langchain._messages import ( + dump_messages, + dump_object, + load_messages, + load_object, + tool_to_schema, +) + +pytest.importorskip("langchain_core") + +from langchain_core.messages import ( # noqa: E402 + AIMessage, + HumanMessage, + ToolMessage, +) + + +def test_object_round_trip_preserves_subtype_and_tool_calls() -> None: + msg = AIMessage( + content="", + tool_calls=[{"name": "t", "args": {"x": 1}, "id": "call-1"}], + ) + loaded = load_object(dump_object(msg)) + assert isinstance(loaded, AIMessage) + assert loaded.tool_calls[0]["id"] == "call-1" + assert loaded.tool_calls[0]["args"] == {"x": 1} + + +def test_messages_round_trip_preserves_order_and_types() -> None: + msgs = [ + HumanMessage(content="hi"), + AIMessage(content="hello"), + ToolMessage(content="result", tool_call_id="c1"), + ] + loaded = load_messages(dump_messages(msgs)) + assert [type(m) for m in loaded] == [HumanMessage, AIMessage, ToolMessage] + assert loaded[2].tool_call_id == "c1" + + +def test_tool_to_schema_carries_parameters() -> None: + from langchain_core.tools import tool + + @tool + def get_weather(city: str) -> str: + """Return the weather for a city.""" + return city + + schema = tool_to_schema(get_weather) + fn = schema["function"] + assert fn["name"] == "get_weather" + assert fn["description"] + assert "city" in fn["parameters"]["properties"] From 8f0540bdb17d2fbea71165fa5d194c4b9f45d295 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 19:19:13 -0500 Subject: [PATCH 13/16] Group same-module imports in the migrated plugin modules ruff's isort (combine-as-imports=false, the repo default) splits aliased imports onto separate statements, so the `name as name` re-export idiom and the `as _shared_*` renames each produced a run of single-name import blocks from the same module. Replace the verbatim re-exports with plain grouped imports plus explicit __all__ lists, and the shadow-renamed internals with namespace imports (_converter.build_data_converter, _runnable_config.strip_runnable_config, _task_cache.cache_key), so each source module contributes a single import statement. --- temporalio/contrib/deepagents/_activity.py | 19 ++--- temporalio/contrib/deepagents/_serde.py | 70 +++++++++++-------- .../contrib/langgraph/_langgraph_config.py | 6 +- temporalio/contrib/langgraph/_task_cache.py | 17 ++++- 4 files changed, 63 insertions(+), 49 deletions(-) diff --git a/temporalio/contrib/deepagents/_activity.py b/temporalio/contrib/deepagents/_activity.py index 066524845..4846d51c2 100644 --- a/temporalio/contrib/deepagents/_activity.py +++ b/temporalio/contrib/deepagents/_activity.py @@ -30,12 +30,7 @@ from typing import Any, Callable from temporalio import activity -from temporalio.contrib._langchain._activity_helpers import ( - auto_heartbeater as _auto_heartbeater, -) -from temporalio.contrib._langchain._activity_helpers import ( - translate_api_error as _translate_api_error, -) +from temporalio.contrib._langchain import _activity_helpers from temporalio.contrib.deepagents import _serde from temporalio.exceptions import ApplicationError @@ -170,7 +165,7 @@ def _build_bound_model(self, input: ModelActivityInput) -> Any: return model @activity.defn(name=INVOKE_MODEL) - @_auto_heartbeater + @_activity_helpers.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) @@ -179,7 +174,7 @@ async def invoke_model(self, input: ModelActivityInput) -> ModelActivityOutput: try: message = await model.ainvoke(messages, config=config) except Exception as exc: - translated = _translate_api_error(exc) + translated = _activity_helpers.translate_api_error(exc) if translated is not None: activity.logger.warning( "Model call failed with an HTTP status error", exc_info=True @@ -189,7 +184,7 @@ async def invoke_model(self, input: ModelActivityInput) -> ModelActivityOutput: return ModelActivityOutput(message=_serde.dump_object(message)) @activity.defn(name=INVOKE_MODEL_STREAMING) - @_auto_heartbeater + @_activity_helpers.auto_heartbeater async def invoke_model_streaming( self, input: ModelActivityInput ) -> ModelActivityOutput: @@ -221,7 +216,7 @@ async def invoke_model_streaming( topic.publish(_serde.dump_object(chunk)) final = chunk if final is None else final + chunk except Exception as exc: - translated = _translate_api_error(exc) + translated = _activity_helpers.translate_api_error(exc) if translated is not None: activity.logger.warning( "Streaming model call failed with an HTTP status error", @@ -232,7 +227,7 @@ async def invoke_model_streaming( return ModelActivityOutput(message=_serde.dump_object(final)) @activity.defn(name=INVOKE_TOOL) - @_auto_heartbeater + @_activity_helpers.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 @@ -256,7 +251,7 @@ async def invoke_tool(self, input: ToolActivityInput) -> ToolActivityOutput: return ToolActivityOutput(message=_serde.dump_object(message)) @activity.defn(name=BACKEND_OP) - @_auto_heartbeater + @_activity_helpers.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 diff --git a/temporalio/contrib/deepagents/_serde.py b/temporalio/contrib/deepagents/_serde.py index 0af2060df..27b472cc4 100644 --- a/temporalio/contrib/deepagents/_serde.py +++ b/temporalio/contrib/deepagents/_serde.py @@ -32,42 +32,50 @@ import dataclasses from typing import Any +from temporalio.contrib._langchain import _converter, _runnable_config, _task_cache from temporalio.contrib._langchain._converter import ( LangChainPayloadConverter, -) -from temporalio.contrib._langchain._converter import ( - build_data_converter as _shared_build_data_converter, -) -from temporalio.contrib._langchain._converter import ( - data_converter as data_converter, -) -from temporalio.contrib._langchain._messages import ( - dump_messages as dump_messages, -) -from temporalio.contrib._langchain._messages import ( - dump_object as dump_object, + data_converter, ) from temporalio.contrib._langchain._messages import ( - load_messages as load_messages, -) -from temporalio.contrib._langchain._messages import ( - load_object as load_object, -) -from temporalio.contrib._langchain._messages import ( - tool_to_schema as tool_to_schema, + dump_messages, + dump_object, + load_messages, + load_object, + tool_to_schema, ) from temporalio.contrib._langchain._passthrough import merge_passthrough_modules -from temporalio.contrib._langchain._runnable_config import is_jsonish from temporalio.contrib._langchain._runnable_config import ( - rebuild_runnable_config as rebuild_runnable_config, + is_jsonish, + rebuild_runnable_config, ) -from temporalio.contrib._langchain._runnable_config import ( - strip_runnable_config as _shared_strip_runnable_config, -) -from temporalio.contrib._langchain._task_cache import TaskResultCache -from temporalio.contrib._langchain._task_cache import cache_key as _shared_cache_key from temporalio.converter import DataConverter +__all__ = [ + "DeepAgentsPayloadConverter", + "Settings", + "build_data_converter", + "cache_key", + "cache_lookup", + "cache_put", + "data_converter", + "default_passthrough_modules", + "dump_backend_result", + "dump_messages", + "dump_object", + "get_settings", + "load_backend_result", + "load_messages", + "load_object", + "rebuild_runnable_config", + "resolve_passthrough_modules", + "result_cache_snapshot", + "set_result_cache", + "set_settings", + "strip_runnable_config", + "tool_to_schema", +] + # --------------------------------------------------------------------------- # Worker-wide dispatch settings # --------------------------------------------------------------------------- @@ -124,7 +132,9 @@ def build_data_converter( Delegates to the shared family implementation; see its docstring for the None / SDK-default / custom-converter contract. """ - return _shared_build_data_converter(user_converter, plugin_name="DeepAgentsPlugin") + return _converter.build_data_converter( + user_converter, plugin_name="DeepAgentsPlugin" + ) # --------------------------------------------------------------------------- @@ -242,7 +252,7 @@ def strip_runnable_config(config: Any) -> dict[str, Any]: those are reconstructed activity-side. Output shape follows the shared (langgraph-vetted) implementation. """ - return _shared_strip_runnable_config( + return _runnable_config.strip_runnable_config( config, configurable_filter=_keep_configurable, metadata_filter=is_jsonish, @@ -258,7 +268,7 @@ def strip_runnable_config(config: Any) -> dict[str, Any]: # ContextVar-backed cache (update / signal handler tasks spawned by the # workflow inherit it automatically); this plugin's instance is distinct from # the langgraph plugin's. -_result_cache = TaskResultCache("_deepagents_result_cache") +_result_cache = _task_cache.TaskResultCache("_deepagents_result_cache") def set_result_cache(cache: dict[str, Any] | None) -> None: @@ -274,7 +284,7 @@ def result_cache_snapshot() -> dict[str, Any] | None: def cache_key(kind: str, call_id: str, args: Any) -> str: """Stable key over ``(kind, call_id, args)`` for cache lookups.""" - return _shared_cache_key(kind, (call_id, args), {}) + return _task_cache.cache_key(kind, (call_id, args), {}) def cache_lookup(key: str) -> tuple[bool, Any]: diff --git a/temporalio/contrib/langgraph/_langgraph_config.py b/temporalio/contrib/langgraph/_langgraph_config.py index baa1084ec..68caf30e1 100644 --- a/temporalio/contrib/langgraph/_langgraph_config.py +++ b/temporalio/contrib/langgraph/_langgraph_config.py @@ -23,9 +23,7 @@ from langgraph.pregel._algo import LazyAtomicCounter from langgraph.runtime import ExecutionInfo, Runtime -from temporalio.contrib._langchain._runnable_config import ( - strip_runnable_config as _shared_strip_runnable_config, -) +from temporalio.contrib._langchain import _runnable_config # The configurable keys the langgraph plugin ships across activity # boundaries (checkpoint/resumption state); everything else in @@ -58,7 +56,7 @@ def strip_runnable_config(config: RunnableConfig | None) -> RunnableConfig: "RunnableConfig", cast( object, - _shared_strip_runnable_config( + _runnable_config.strip_runnable_config( config, configurable_keys=_KEPT_CONFIGURABLE_KEYS ), ), diff --git a/temporalio/contrib/langgraph/_task_cache.py b/temporalio/contrib/langgraph/_task_cache.py index f6c046c3c..a2e27f020 100644 --- a/temporalio/contrib/langgraph/_task_cache.py +++ b/temporalio/contrib/langgraph/_task_cache.py @@ -13,9 +13,20 @@ from typing import Any -from temporalio.contrib._langchain._task_cache import TaskResultCache -from temporalio.contrib._langchain._task_cache import cache_key as cache_key -from temporalio.contrib._langchain._task_cache import task_id as task_id +from temporalio.contrib._langchain._task_cache import ( + TaskResultCache, + cache_key, + task_id, +) + +__all__ = [ + "cache_key", + "cache_lookup", + "cache_put", + "get_task_cache", + "set_task_cache", + "task_id", +] _cache = TaskResultCache("_temporal_task_cache") From 100101a3c95b92a589112ac07598f868a389621a Mon Sep 17 00:00:00 2001 From: David Hyde Date: Wed, 15 Jul 2026 19:24:16 -0500 Subject: [PATCH 14/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- temporalio/contrib/_langchain/_runnable_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/temporalio/contrib/_langchain/_runnable_config.py b/temporalio/contrib/_langchain/_runnable_config.py index 17d78825b..b0d03e147 100644 --- a/temporalio/contrib/_langchain/_runnable_config.py +++ b/temporalio/contrib/_langchain/_runnable_config.py @@ -10,8 +10,7 @@ def is_jsonish(value: Any) -> bool: - """Return True for values that survive a plain JSON round-trip unchanged.""" - if value is None or isinstance(value, (str, int, float, bool)): + """Return True for values that are JSON-serializable with the standard encoder.""" return True if isinstance(value, (list, tuple)): return all(is_jsonish(v) for v in value) From b306dc871a09079305cf8a8328b2a107b376b649 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 23:14:30 -0500 Subject: [PATCH 15/16] Revert "Potential fix for pull request finding" This reverts commit 100101a3c95b92a589112ac07598f868a389621a. --- temporalio/contrib/_langchain/_runnable_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/temporalio/contrib/_langchain/_runnable_config.py b/temporalio/contrib/_langchain/_runnable_config.py index b0d03e147..17d78825b 100644 --- a/temporalio/contrib/_langchain/_runnable_config.py +++ b/temporalio/contrib/_langchain/_runnable_config.py @@ -10,7 +10,8 @@ def is_jsonish(value: Any) -> bool: - """Return True for values that are JSON-serializable with the standard encoder.""" + """Return True for values that survive a plain JSON round-trip unchanged.""" + 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) From 61a182ea1cee66e83e218064ae95d6d8065d05f8 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 23:16:17 -0500 Subject: [PATCH 16/16] Correct the is_jsonish docstring's round-trip claim Tuples pass the check but rehydrate as lists, so a JSON round-trip preserves content, not container type. Applies the substance of the reverted autofix without its syntax damage. --- temporalio/contrib/_langchain/_runnable_config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/temporalio/contrib/_langchain/_runnable_config.py b/temporalio/contrib/_langchain/_runnable_config.py index 17d78825b..8ee9d3a71 100644 --- a/temporalio/contrib/_langchain/_runnable_config.py +++ b/temporalio/contrib/_langchain/_runnable_config.py @@ -10,7 +10,12 @@ def is_jsonish(value: Any) -> bool: - """Return True for values that survive a plain JSON round-trip unchanged.""" + """Return True for values representable as plain JSON. + + Containers are checked recursively. Tuples are accepted — they encode + as JSON arrays and rehydrate as lists, so a round-trip preserves + content but may change the container type. + """ if value is None or isinstance(value, (str, int, float, bool)): return True if isinstance(value, (list, tuple)):