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/_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/_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/_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/_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/_langchain/_runnable_config.py b/temporalio/contrib/_langchain/_runnable_config.py new file mode 100644 index 000000000..8ee9d3a71 --- /dev/null +++ b/temporalio/contrib/_langchain/_runnable_config.py @@ -0,0 +1,103 @@ +"""``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 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)): + 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/_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/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..4846d51c2 --- /dev/null +++ b/temporalio/contrib/deepagents/_activity.py @@ -0,0 +1,279 @@ +"""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 +import importlib +from datetime import timedelta +from typing import Any, Callable + +from temporalio import activity +from temporalio.contrib._langchain import _activity_helpers +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 _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. + """ + # 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) + + +class DeepAgentActivities: + """Holds the worker-side dependencies and exposes the four activities. + + An instance is created by ``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) + @_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) + 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 = _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 + ) + raise translated from exc + raise + return ModelActivityOutput(message=_serde.dump_object(message)) + + @activity.defn(name=INVOKE_MODEL_STREAMING) + @_activity_helpers.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 = _activity_helpers.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) + @_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 + + 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) + @_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 + + 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..2d97f992d --- /dev/null +++ b/temporalio/contrib/deepagents/_model.py @@ -0,0 +1,372 @@ +"""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 + +import importlib +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from datetime import timedelta +from typing import Any + +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 + # 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 + + def patched_resolve_model(model: Any) -> Any: + if workflow.in_workflow(): + return _wrap_model_arg(model) + return _original_resolve_model(model) + + setattr(_graph, "resolve_model", patched_resolve_model) + + 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) + + 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: + _graph = importlib.import_module("deepagents.graph") + + setattr(_graph, "resolve_model", _original_resolve_model) + _original_resolve_model = None + if _original_create_deep_agent is not None: + deepagents = importlib.import_module("deepagents") + + 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 new file mode 100644 index 000000000..1efecd85f --- /dev/null +++ b/temporalio/contrib/deepagents/_plugin.py @@ -0,0 +1,265 @@ +"""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 collections.abc import AsyncIterator, Sequence +from contextlib import asynccontextmanager +from dataclasses import replace +from datetime import timedelta +from typing import Any, Callable + +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 + +# 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. + + 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 < _MIN_PYTHON: + 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, _tools + + _model.install_model_patch() + _tools.install_backend_async_patch() + patched = True + 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( + f"DeepAgentsPlugin could not patch create_deep_agent ({exc}); " + "use explicit TemporalModel(...) instances to route model calls " + "through activities.", + stacklevel=2, + ) + # Installed for the life of the process, never uninstalled — see + # _install_langsmith_temporal_override for why. + _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, _tools + + _model.uninstall_model_patch() + _tools.uninstall_backend_async_patch() + + # -- 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 + + +def _install_langsmith_temporal_override() -> None: + """Route LangSmith's thread hop inline while in a workflow. + + 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``. + """ + try: + from temporalio.contrib._langchain._aio_to_thread import ( + install_aio_to_thread_override, + ) + + install_aio_to_thread_override() + 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..27b472cc4 --- /dev/null +++ b/temporalio/contrib/deepagents/_serde.py @@ -0,0 +1,337 @@ +"""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 dataclasses +from typing import Any + +from temporalio.contrib._langchain import _converter, _runnable_config, _task_cache +from temporalio.contrib._langchain._converter import ( + LangChainPayloadConverter, + data_converter, +) +from temporalio.contrib._langchain._messages import ( + 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, + rebuild_runnable_config, +) +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 +# --------------------------------------------------------------------------- +# +# 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 +# --------------------------------------------------------------------------- + + +# The family converter, kept under this plugin's historical name. +DeepAgentsPayloadConverter = LangChainPayloadConverter + + +def build_data_converter( + user_converter: DataConverter | None, +) -> DataConverter: + """Compose the plugin's converter with whatever the caller already set. + + Delegates to the shared family implementation; see its docstring for the + None / SDK-default / custom-converter contract. + """ + return _converter.build_data_converter( + user_converter, plugin_name="DeepAgentsPlugin" + ) + + +# --------------------------------------------------------------------------- +# LangChain object (de)serialization +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# 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`` 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 + :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 + + +# --------------------------------------------------------------------------- +# RunnableConfig strip / rebuild +# --------------------------------------------------------------------------- + + +# 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`` 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. + """ + return _runnable_config.strip_runnable_config( + config, + configurable_filter=_keep_configurable, + metadata_filter=is_jsonish, + ) + + +# --------------------------------------------------------------------------- +# 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. 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 = _task_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_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() + 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.""" + return _task_cache.cache_key(kind, (call_id, args), {}) + + +def cache_lookup(key: str) -> tuple[bool, Any]: + """Return ``(hit, value)`` for ``key`` in the active cache.""" + 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.""" + _result_cache.put(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.""" + return merge_passthrough_modules(_DEFAULT_PASSTHROUGH, user) diff --git a/temporalio/contrib/deepagents/_tools.py b/temporalio/contrib/deepagents/_tools.py new file mode 100644 index 000000000..1235224c6 --- /dev/null +++ b/temporalio/contrib/deepagents/_tools.py @@ -0,0 +1,517 @@ +"""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 importlib +import threading +import uuid as _uuid +import warnings +import weakref +from collections.abc import Mapping +from datetime import timedelta +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable + +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] = {} +# 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 +# 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.""" + 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]: + """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, + ) + 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( + 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 +# --------------------------------------------------------------------------- + +# 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. + """ + # 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 + 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 + BackendProtocol = importlib.import_module( + "deepagents.backends.protocol" + ).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 +# 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) + # 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 + + 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..749b1ab96 --- /dev/null +++ b/temporalio/contrib/deepagents/testing.py @@ -0,0 +1,141 @@ +"""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`` 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; +* :class:`MockTool` — a scripted ``BaseTool`` for exercising the tool seam. + +Importing this module has no process-wide side effects. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any + +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 = 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: 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: list[str] | None = None, + run_manager: AsyncCallbackManagerForLLMRun | None = 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..26f542cb3 --- /dev/null +++ b/temporalio/contrib/deepagents/workflow.py @@ -0,0 +1,253 @@ +"""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 collections.abc import Mapping +from typing import Any + +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/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/_langgraph_config.py b/temporalio/contrib/langgraph/_langgraph_config.py index 90c6c810d..68caf30e1 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,21 @@ from langgraph.pregel._algo import LazyAtomicCounter from langgraph.runtime import ExecutionInfo, Runtime +from temporalio.contrib._langchain import _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 +46,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, + _runnable_config.strip_runnable_config( + config, configurable_keys=_KEPT_CONFIGURABLE_KEYS + ), + ), + ) def get_langgraph_config() -> dict[str, Any]: @@ -124,17 +122,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/temporalio/contrib/langgraph/_task_cache.py b/temporalio/contrib/langgraph/_task_cache.py index ab3e683d7..a2e27f020 100644 --- a/temporalio/contrib/langgraph/_task_cache.py +++ b/temporalio/contrib/langgraph/_task_cache.py @@ -3,81 +3,49 @@ 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, + cache_key, + task_id, ) +__all__ = [ + "cache_key", + "cache_lookup", + "cache_put", + "get_task_cache", + "set_task_cache", + "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/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/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/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/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..b32a6f12b --- /dev/null +++ b/tests/contrib/deepagents/test_backends.py @@ -0,0 +1,315 @@ +"""``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 gc +import sys +import uuid +from datetime import timedelta +from pathlib import Path + +import pytest + +from temporalio.testing import WorkflowEnvironment + +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.contrib.deepagents._tools import ( + register_backend, + registered_backends, +) +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}" + + +# 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. +@workflow.defn +class StateBackendWorkflow: + @workflow.run + async def run(self) -> str: + backend = StateBackend() + # 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 +# 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", + backend=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: WorkflowEnvironment) -> 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 + + +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 + # 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()).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: 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. + + 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 + + +@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: 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. + + 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 diff --git a/tests/contrib/deepagents/test_checkpointer.py b/tests/contrib/deepagents/test_checkpointer.py new file mode 100644 index 000000000..851bbb018 --- /dev/null +++ b/tests/contrib/deepagents/test_checkpointer.py @@ -0,0 +1,98 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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 + +# 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: + """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: 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. + 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..0603f6eb8 --- /dev/null +++ b/tests/contrib/deepagents/test_continue_as_new.py @@ -0,0 +1,98 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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: WorkflowEnvironment) -> 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..21cf5de52 --- /dev/null +++ b/tests/contrib/deepagents/test_failures.py @@ -0,0 +1,121 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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._langchain._activity_helpers import ( + translate_api_error as _translate_api_error, +) +from temporalio.contrib.deepagents import DeepAgentsPlugin, DeepAgentsWorkflowError +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: WorkflowEnvironment) -> 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() -> 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..6124d41f1 --- /dev/null +++ b/tests/contrib/deepagents/test_hitl.py @@ -0,0 +1,139 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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 + +# 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 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: WorkflowEnvironment) -> 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..37a98ef00 --- /dev/null +++ b/tests/contrib/deepagents/test_model_activity.py @@ -0,0 +1,103 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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: 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)}, + ) + 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: WorkflowEnvironment) -> 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..29b373da6 --- /dev/null +++ b/tests/contrib/deepagents/test_native_e2e.py @@ -0,0 +1,141 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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 + +# 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 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: WorkflowEnvironment) -> 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: 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( + 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..ac43a4034 --- /dev/null +++ b/tests/contrib/deepagents/test_replay.py @@ -0,0 +1,65 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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: WorkflowEnvironment) -> 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..2b32b9dee --- /dev/null +++ b/tests/contrib/deepagents/test_side_effects.py @@ -0,0 +1,74 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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: WorkflowEnvironment) -> 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..6b65aa0af --- /dev/null +++ b/tests/contrib/deepagents/test_streaming.py @@ -0,0 +1,107 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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: WorkflowEnvironment) -> 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: WorkflowEnvironment) -> 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..49a407e9d --- /dev/null +++ b/tests/contrib/deepagents/test_subagents.py @@ -0,0 +1,90 @@ +"""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 + +from temporalio.testing import WorkflowEnvironment + +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 + +# 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 + +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: WorkflowEnvironment) -> 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..1fc1ff212 --- /dev/null +++ b/tests/contrib/deepagents/test_tools.py @@ -0,0 +1,238 @@ +"""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 collections.abc import Sequence +from datetime import timedelta +from types import SimpleNamespace +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" +) + +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" + +# 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.""" + return f"weather:{city}" + + +@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) + ) + # 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 +async def test_activity_as_tool(env: WorkflowEnvironment) -> 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: WorkflowEnvironment) -> 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: 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")]) + assert len(recwarn) == 0 + + 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: + 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: 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) + 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: 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 + ) + + 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) 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) 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_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 + ) 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"] 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 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, + ) 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"