temporalio-deepagents 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ """Temporal plugin for LangChain Deep Agents.
2
+
3
+ Make an existing Deep Agent durable by adding one plugin: build your agent with
4
+ ``create_deep_agent(...)`` inside a ``@workflow.defn`` and add
5
+ ``plugins=[DeepAgentsPlugin(...)]`` to your Client or Worker. Each LLM call and
6
+ each I/O tool call becomes a Temporal activity, while the agent's control loop
7
+ runs — and deterministically replays — inside the workflow.
8
+
9
+ .. warning::
10
+ This package is experimental and may change in future versions.
11
+
12
+ The public names are imported lazily so ``import temporalio.deepagents``
13
+ succeeds before LangChain is installed; touching a name that needs LangChain
14
+ imports it on first access.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING
20
+
21
+ __all__ = [
22
+ "DeepAgentsPlugin",
23
+ "TemporalModel",
24
+ "TemporalBackend",
25
+ "activity_as_tool",
26
+ "tool_as_activity",
27
+ "run_deep_agent",
28
+ "create_temporal_deep_agent",
29
+ "DeepAgentsWorkflowError",
30
+ ]
31
+
32
+ if TYPE_CHECKING:
33
+ from temporalio.deepagents._model import TemporalModel
34
+ from temporalio.deepagents._plugin import DeepAgentsPlugin
35
+ from temporalio.deepagents._tools import (
36
+ TemporalBackend,
37
+ activity_as_tool,
38
+ tool_as_activity,
39
+ )
40
+ from temporalio.deepagents.workflow import (
41
+ DeepAgentsWorkflowError,
42
+ create_temporal_deep_agent,
43
+ run_deep_agent,
44
+ )
45
+
46
+
47
+ def __getattr__(name: str) -> object:
48
+ if name == "DeepAgentsPlugin":
49
+ from temporalio.deepagents._plugin import DeepAgentsPlugin
50
+
51
+ return DeepAgentsPlugin
52
+ if name == "TemporalModel":
53
+ from temporalio.deepagents._model import TemporalModel
54
+
55
+ return TemporalModel
56
+ if name in ("TemporalBackend", "activity_as_tool", "tool_as_activity"):
57
+ from temporalio.deepagents import _tools
58
+
59
+ return getattr(_tools, name)
60
+ if name in (
61
+ "DeepAgentsWorkflowError",
62
+ "run_deep_agent",
63
+ "create_temporal_deep_agent",
64
+ ):
65
+ from temporalio.deepagents import workflow
66
+
67
+ return getattr(workflow, name)
68
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,363 @@
1
+ """The activities that carry every nondeterministic Deep Agents operation.
2
+
3
+ The Deep Agents control loop runs *inside* the workflow; the operations that
4
+ must not run there — talking to an LLM, executing a tool that does real I/O, or
5
+ touching a real filesystem / shell backend — are moved out to these activities.
6
+
7
+ Each activity is a method on :class:`DeepAgentActivities` so the worker-only
8
+ dependencies (the ``model_provider`` that builds real chat models from a name,
9
+ the streaming batch interval) can be captured on the instance rather than
10
+ smuggled through activity inputs. API keys therefore live on the worker, never
11
+ in a workflow input or in history.
12
+
13
+ Every method:
14
+
15
+ * takes a single serializable dataclass in and returns a single dataclass out
16
+ (LangChain objects travel as their ``dumpd`` JSON form via
17
+ :mod:`temporalio.deepagents._serde`);
18
+ * translates the LLM SDK's HTTP error into Temporal's retry contract so a 429
19
+ honors the upstream ``retry-after`` instead of hammering it;
20
+ * heartbeats on a background task so a slow (thinking-mode / long-context) call
21
+ is not mistaken for a stuck worker.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import dataclasses
28
+ import importlib
29
+ from datetime import timedelta
30
+ from functools import wraps
31
+ from typing import Any, Callable
32
+
33
+ from temporalio import activity
34
+ from temporalio.deepagents import _serde
35
+ from temporalio.exceptions import ApplicationError
36
+
37
+ # Activity type names. The workflow dispatches by these strings, so the
38
+ # in-workflow model / tool stubs never import the activity class itself.
39
+ INVOKE_MODEL = "deepagents.invoke_model"
40
+ INVOKE_MODEL_STREAMING = "deepagents.invoke_model_streaming"
41
+ INVOKE_TOOL = "deepagents.invoke_tool"
42
+ BACKEND_OP = "deepagents.backend_op"
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Boundary payloads
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ @dataclasses.dataclass
51
+ class ModelActivityInput:
52
+ """A single LLM request.
53
+
54
+ ``model_name`` is resolved to a real model by the worker's
55
+ ``model_provider``; the workflow never ships credentials.
56
+ """
57
+
58
+ model_name: str
59
+ messages: list[Any]
60
+ """Messages in ``langchain_core.load.dumpd`` form."""
61
+ tool_schemas: list[dict[str, Any]] = dataclasses.field(default_factory=list)
62
+ """OpenAI-format tool advertisements (name + description + argument schema)."""
63
+ bind_kwargs: dict[str, Any] = dataclasses.field(default_factory=dict)
64
+ config: dict[str, Any] = dataclasses.field(default_factory=dict)
65
+ """A stripped ``RunnableConfig`` (see :func:`_serde.strip_runnable_config`)."""
66
+ streaming_topic: str | None = None
67
+
68
+
69
+ @dataclasses.dataclass
70
+ class ModelActivityOutput:
71
+ """The model's reply.
72
+
73
+ ``message`` is an ``AIMessage`` in ``dumpd`` form, carrying tool calls,
74
+ usage, and response metadata.
75
+ """
76
+
77
+ message: Any
78
+
79
+
80
+ @dataclasses.dataclass
81
+ class ToolActivityInput:
82
+ """One tool execution routed to an activity."""
83
+
84
+ tool_name: str
85
+ tool_call_id: str
86
+ args: dict[str, Any]
87
+ config: dict[str, Any] = dataclasses.field(default_factory=dict)
88
+
89
+
90
+ @dataclasses.dataclass
91
+ class ToolActivityOutput:
92
+ """A tool result as a ``ToolMessage`` in ``dumpd`` form."""
93
+
94
+ message: Any
95
+
96
+
97
+ @dataclasses.dataclass
98
+ class BackendOpInput:
99
+ """A single filesystem / shell / store operation for a wrapped backend."""
100
+
101
+ backend_ref: str
102
+ """Key identifying which registered backend to act on."""
103
+ op: str
104
+ """Backend method name, e.g. ``ls`` / ``read_file`` / ``write_file`` / ``execute``."""
105
+ args: list[Any] = dataclasses.field(default_factory=list)
106
+ kwargs: dict[str, Any] = dataclasses.field(default_factory=dict)
107
+
108
+
109
+ @dataclasses.dataclass
110
+ class BackendOpOutput:
111
+ """The backend operation's return value.
112
+
113
+ Deepagents protocol dataclasses (``WriteResult`` / ``ReadResult`` / …)
114
+ ride in the tagged form produced by ``_serde.dump_backend_result`` so the
115
+ in-workflow stub can rebuild the real type; plain JSON values pass
116
+ through unchanged.
117
+ """
118
+
119
+ result: Any
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Heartbeating + error translation
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def _auto_heartbeater(fn: Callable) -> Callable:
128
+ """Heartbeat at half the configured ``heartbeat_timeout`` while ``fn`` runs.
129
+
130
+ Long LLM calls (thinking mode, long context, streaming accumulation) can run
131
+ well past a scheduler's patience; without a heartbeat Temporal would cancel
132
+ them and surface a ``HeartbeatTimeoutError`` instead of the real problem.
133
+ """
134
+
135
+ @wraps(fn)
136
+ async def wrapped(*args: Any, **kwargs: Any) -> Any:
137
+ heartbeat_timeout = activity.info().heartbeat_timeout
138
+ beat_task: asyncio.Task | None = None
139
+ if heartbeat_timeout:
140
+ interval = heartbeat_timeout.total_seconds() / 2
141
+
142
+ async def beat() -> None:
143
+ while True:
144
+ activity.heartbeat()
145
+ await asyncio.sleep(interval)
146
+
147
+ beat_task = asyncio.create_task(beat())
148
+ try:
149
+ return await fn(*args, **kwargs)
150
+ finally:
151
+ if beat_task is not None:
152
+ beat_task.cancel()
153
+ # Let the cancellation land before returning so no pending task
154
+ # outlives the activity (a bare ``cancel()`` leaves the task to
155
+ # be destroyed while pending if the loop shuts down first).
156
+ # ``asyncio.wait`` never re-raises the task's CancelledError.
157
+ await asyncio.wait([beat_task])
158
+
159
+ return wrapped
160
+
161
+
162
+ def _translate_api_error(exc: Exception) -> ApplicationError | None:
163
+ """Map an LLM SDK HTTP error onto Temporal's retry contract.
164
+
165
+ Works by duck typing so neither ``openai`` nor ``anthropic`` needs to be
166
+ imported here: both expose ``status_code`` and ``response.headers``. Returns
167
+ ``None`` when ``exc`` is not a recognizable HTTP status error, so the caller
168
+ can fall through to its generic handling.
169
+ """
170
+ status = getattr(exc, "status_code", None)
171
+ if status is None:
172
+ return None
173
+ headers: dict[str, Any] = {}
174
+ response = getattr(exc, "response", None)
175
+ if response is not None:
176
+ headers = dict(getattr(response, "headers", {}) or {})
177
+ # Case-insensitive header access.
178
+ lower = {str(k).lower(): v for k, v in headers.items()}
179
+
180
+ retryable = status in (408, 409, 429) or 500 <= status < 600
181
+ should_retry = lower.get("x-should-retry")
182
+ if should_retry == "false":
183
+ retryable = False
184
+ elif should_retry == "true":
185
+ retryable = True
186
+
187
+ delay_ms = lower.get("retry-after-ms")
188
+ retry_after = lower.get("retry-after")
189
+ next_delay: timedelta | None = None
190
+ try:
191
+ if delay_ms is not None:
192
+ next_delay = timedelta(milliseconds=int(delay_ms))
193
+ elif retry_after is not None:
194
+ next_delay = timedelta(seconds=int(retry_after))
195
+ except (TypeError, ValueError):
196
+ next_delay = None
197
+
198
+ return ApplicationError(
199
+ str(exc),
200
+ type=type(exc).__name__,
201
+ non_retryable=not retryable,
202
+ next_retry_delay=next_delay,
203
+ )
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # The activities
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def _default_model_provider(model_name: str) -> Any:
212
+ """Build a chat model from a name string with LLM-SDK retries disabled.
213
+
214
+ Temporal owns retries; the model client must not also retry, or a single
215
+ logical attempt fans out into nested retry storms that Temporal can neither
216
+ see nor bound.
217
+ """
218
+ # importlib: `langchain` (unlike langchain-core) is absent on Python 3.10
219
+ # environments where the deepagents extra cannot install; a static import
220
+ # here fails type-checking there.
221
+ init_chat_model = importlib.import_module("langchain.chat_models").init_chat_model
222
+
223
+ return init_chat_model(model_name, max_retries=0)
224
+
225
+
226
+ class DeepAgentActivities:
227
+ """Holds the worker-side dependencies and exposes the four activities.
228
+
229
+ An instance is created by ``DeepAgentsPlugin``
230
+ and its bound methods are registered on the worker.
231
+ """
232
+
233
+ def __init__(
234
+ self,
235
+ *,
236
+ model_provider: Callable[[str], Any] | None = None,
237
+ streaming_batch_interval: timedelta = timedelta(milliseconds=100),
238
+ ) -> None:
239
+ """Store the worker-side model provider + streaming configuration."""
240
+ self._model_provider = model_provider or _default_model_provider
241
+ self._streaming_batch_interval = streaming_batch_interval
242
+
243
+ def _build_bound_model(self, input: ModelActivityInput) -> Any:
244
+ model = self._model_provider(input.model_name)
245
+ if input.tool_schemas:
246
+ model = model.bind_tools(input.tool_schemas)
247
+ if input.bind_kwargs:
248
+ model = model.bind(**input.bind_kwargs)
249
+ return model
250
+
251
+ @activity.defn(name=INVOKE_MODEL)
252
+ @_auto_heartbeater
253
+ async def invoke_model(self, input: ModelActivityInput) -> ModelActivityOutput:
254
+ """Run exactly one LLM call and return the resulting ``AIMessage``."""
255
+ messages = _serde.load_messages(input.messages)
256
+ config = _serde.rebuild_runnable_config(input.config)
257
+ model = self._build_bound_model(input)
258
+ try:
259
+ message = await model.ainvoke(messages, config=config)
260
+ except Exception as exc:
261
+ translated = _translate_api_error(exc)
262
+ if translated is not None:
263
+ activity.logger.warning(
264
+ "Model call failed with an HTTP status error", exc_info=True
265
+ )
266
+ raise translated from exc
267
+ raise
268
+ return ModelActivityOutput(message=_serde.dump_object(message))
269
+
270
+ @activity.defn(name=INVOKE_MODEL_STREAMING)
271
+ @_auto_heartbeater
272
+ async def invoke_model_streaming(
273
+ self, input: ModelActivityInput
274
+ ) -> ModelActivityOutput:
275
+ """Stream one LLM call, publishing chunk batches to ``streaming_topic``.
276
+
277
+ Token-level deltas are coalesced at ``streaming_batch_interval`` and
278
+ pushed to external subscribers via the shared workflow-streams topic; the
279
+ aggregated final ``AIMessage`` is returned to the workflow so the
280
+ durable result is identical to the non-streaming path.
281
+ """
282
+ from temporalio.contrib.workflow_streams import WorkflowStreamClient
283
+
284
+ messages = _serde.load_messages(input.messages)
285
+ config = _serde.rebuild_runnable_config(input.config)
286
+ model = self._build_bound_model(input)
287
+
288
+ final: Any = None
289
+ try:
290
+ async with WorkflowStreamClient.from_within_activity(
291
+ batch_interval=self._streaming_batch_interval
292
+ ) as client:
293
+ topic = (
294
+ client.topic(input.streaming_topic)
295
+ if input.streaming_topic
296
+ else None
297
+ )
298
+ async for chunk in model.astream(messages, config=config):
299
+ if topic is not None:
300
+ topic.publish(_serde.dump_object(chunk))
301
+ final = chunk if final is None else final + chunk
302
+ except Exception as exc:
303
+ translated = _translate_api_error(exc)
304
+ if translated is not None:
305
+ activity.logger.warning(
306
+ "Streaming model call failed with an HTTP status error",
307
+ exc_info=True,
308
+ )
309
+ raise translated from exc
310
+ raise
311
+ return ModelActivityOutput(message=_serde.dump_object(final))
312
+
313
+ @activity.defn(name=INVOKE_TOOL)
314
+ @_auto_heartbeater
315
+ async def invoke_tool(self, input: ToolActivityInput) -> ToolActivityOutput:
316
+ """Execute one registered tool and return its ``ToolMessage``."""
317
+ from temporalio.deepagents._tools import get_registered_tool
318
+
319
+ tool = get_registered_tool(input.tool_name)
320
+ if tool is None:
321
+ raise ApplicationError(
322
+ f"Tool {input.tool_name!r} is not registered on this worker. "
323
+ f"Wrap it with tool_as_activity(...) or activity_as_tool(...).",
324
+ type="DeepAgentsUnknownTool",
325
+ non_retryable=True,
326
+ )
327
+ config = _serde.rebuild_runnable_config(input.config)
328
+ tool_call = {
329
+ "name": input.tool_name,
330
+ "args": input.args,
331
+ "id": input.tool_call_id,
332
+ "type": "tool_call",
333
+ }
334
+ message = await tool.ainvoke(tool_call, config=config)
335
+ return ToolActivityOutput(message=_serde.dump_object(message))
336
+
337
+ @activity.defn(name=BACKEND_OP)
338
+ @_auto_heartbeater
339
+ async def backend_op(self, input: BackendOpInput) -> BackendOpOutput:
340
+ """Run one operation against a registered (real-I/O) backend."""
341
+ from temporalio.deepagents._tools import lookup_backend
342
+
343
+ backend = lookup_backend(input.backend_ref)
344
+ if backend is None:
345
+ raise ApplicationError(
346
+ f"Backend {input.backend_ref!r} is not registered on this worker.",
347
+ type="DeepAgentsUnknownBackend",
348
+ non_retryable=True,
349
+ )
350
+ method = getattr(backend, input.op, None)
351
+ if method is None:
352
+ raise ApplicationError(
353
+ f"Backend {input.backend_ref!r} has no operation {input.op!r}.",
354
+ type="DeepAgentsUnknownBackendOp",
355
+ non_retryable=True,
356
+ )
357
+ result = method(*input.args, **input.kwargs)
358
+ if asyncio.iscoroutine(result):
359
+ result = await result
360
+ # Protocol results are plain dataclasses whose attributes the
361
+ # middleware reads in-workflow — tag them so the stub can rebuild
362
+ # the real type instead of receiving a decayed dict.
363
+ return BackendOpOutput(result=_serde.dump_backend_result(result))