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.
- temporalio/deepagents/__init__.py +68 -0
- temporalio/deepagents/_activity.py +363 -0
- temporalio/deepagents/_model.py +395 -0
- temporalio/deepagents/_plugin.py +307 -0
- temporalio/deepagents/_serde.py +412 -0
- temporalio/deepagents/_tools.py +679 -0
- temporalio/deepagents/py.typed +0 -0
- temporalio/deepagents/testing.py +141 -0
- temporalio/deepagents/workflow.py +398 -0
- temporalio_deepagents-0.0.1.dist-info/METADATA +367 -0
- temporalio_deepagents-0.0.1.dist-info/RECORD +13 -0
- temporalio_deepagents-0.0.1.dist-info/WHEEL +4 -0
- temporalio_deepagents-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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))
|