agentdeck-sdk 3.1.0__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.
Files changed (104) hide show
  1. agentdeck/README.md +50 -0
  2. agentdeck/__init__.py +51 -0
  3. agentdeck/adapters/__init__.py +5 -0
  4. agentdeck/adapters/control/__init__.py +1 -0
  5. agentdeck/adapters/control/memory/__init__.py +5 -0
  6. agentdeck/adapters/control/memory/port.py +25 -0
  7. agentdeck/adapters/control/sqlite/__init__.py +5 -0
  8. agentdeck/adapters/control/sqlite/port.py +111 -0
  9. agentdeck/adapters/engines/__init__.py +1 -0
  10. agentdeck/adapters/engines/langgraph/__init__.py +8 -0
  11. agentdeck/adapters/engines/langgraph/checkpointer.py +205 -0
  12. agentdeck/adapters/engines/langgraph/engine.py +410 -0
  13. agentdeck/adapters/engines/openai_agents/__init__.py +9 -0
  14. agentdeck/adapters/engines/openai_agents/engine.py +321 -0
  15. agentdeck/adapters/engines/openai_agents/reconcile.py +178 -0
  16. agentdeck/adapters/engines/openai_agents/runconfig.py +118 -0
  17. agentdeck/adapters/engines/openai_agents/sessions.py +100 -0
  18. agentdeck/adapters/engines/openai_agents/translate.py +124 -0
  19. agentdeck/adapters/engines/stub/__init__.py +5 -0
  20. agentdeck/adapters/engines/stub/engine.py +100 -0
  21. agentdeck/adapters/stores/__init__.py +1 -0
  22. agentdeck/adapters/stores/memory/__init__.py +5 -0
  23. agentdeck/adapters/stores/memory/store.py +148 -0
  24. agentdeck/adapters/stores/postgres/__init__.py +5 -0
  25. agentdeck/adapters/stores/postgres/store.py +374 -0
  26. agentdeck/adapters/stores/redis/__init__.py +5 -0
  27. agentdeck/adapters/stores/redis/store.py +359 -0
  28. agentdeck/adapters/stores/sqlite/__init__.py +5 -0
  29. agentdeck/adapters/stores/sqlite/store.py +338 -0
  30. agentdeck/adapters/telemetry/__init__.py +1 -0
  31. agentdeck/adapters/telemetry/langfuse/__init__.py +18 -0
  32. agentdeck/adapters/telemetry/langfuse/client.py +180 -0
  33. agentdeck/adapters/telemetry/langfuse/sink.py +366 -0
  34. agentdeck/adapters/telemetry/langfuse/trace.py +88 -0
  35. agentdeck/adapters/tools/__init__.py +1 -0
  36. agentdeck/adapters/tools/mcp/__init__.py +18 -0
  37. agentdeck/adapters/tools/mcp/lifecycle.py +178 -0
  38. agentdeck/adapters/tools/mcp/source.py +47 -0
  39. agentdeck/adapters/tools/mcp/transport.py +234 -0
  40. agentdeck/adapters/tools/mcp/wiring.py +63 -0
  41. agentdeck/authoring/__init__.py +22 -0
  42. agentdeck/authoring/agent.py +169 -0
  43. agentdeck/authoring/compile.py +250 -0
  44. agentdeck/authoring/graphs.py +145 -0
  45. agentdeck/authoring/hooks.py +117 -0
  46. agentdeck/authoring/injection.py +233 -0
  47. agentdeck/authoring/instructions.py +80 -0
  48. agentdeck/authoring/interrupts.py +37 -0
  49. agentdeck/authoring/nodes.py +140 -0
  50. agentdeck/authoring/runners/__init__.py +6 -0
  51. agentdeck/authoring/runners/agent.py +171 -0
  52. agentdeck/authoring/runners/workflow.py +99 -0
  53. agentdeck/authoring/skills.py +56 -0
  54. agentdeck/authoring/state.py +44 -0
  55. agentdeck/authoring/timers.py +44 -0
  56. agentdeck/authoring/tools.py +147 -0
  57. agentdeck/authoring/web_search.py +43 -0
  58. agentdeck/authoring/workflow.py +266 -0
  59. agentdeck/cli.py +56 -0
  60. agentdeck/composition.py +213 -0
  61. agentdeck/core/__init__.py +110 -0
  62. agentdeck/core/base.py +47 -0
  63. agentdeck/core/content.py +166 -0
  64. agentdeck/core/context.py +136 -0
  65. agentdeck/core/control.py +150 -0
  66. agentdeck/core/events.py +468 -0
  67. agentdeck/core/invocable.py +38 -0
  68. agentdeck/core/ports/__init__.py +26 -0
  69. agentdeck/core/ports/control.py +35 -0
  70. agentdeck/core/ports/engine.py +66 -0
  71. agentdeck/core/ports/sink.py +53 -0
  72. agentdeck/core/ports/store.py +170 -0
  73. agentdeck/core/ports/tools.py +57 -0
  74. agentdeck/core/reporting.py +75 -0
  75. agentdeck/core/status.py +75 -0
  76. agentdeck/deck.py +894 -0
  77. agentdeck/errors.py +67 -0
  78. agentdeck/mcp.py +82 -0
  79. agentdeck/observers.py +111 -0
  80. agentdeck/py.typed +0 -0
  81. agentdeck/runtime/__init__.py +1 -0
  82. agentdeck/runtime/capture.py +32 -0
  83. agentdeck/runtime/config.default.yaml +38 -0
  84. agentdeck/runtime/discovery.py +175 -0
  85. agentdeck/runtime/dispatch.py +440 -0
  86. agentdeck/runtime/registry.py +173 -0
  87. agentdeck/runtime/service.py +671 -0
  88. agentdeck/runtime/settings.py +568 -0
  89. agentdeck/serve.py +330 -0
  90. agentdeck/skills/__init__.py +114 -0
  91. agentdeck/skills/bundle.py +65 -0
  92. agentdeck/surfaces/__init__.py +4 -0
  93. agentdeck/surfaces/cli/__init__.py +7 -0
  94. agentdeck/surfaces/cli/chat.py +88 -0
  95. agentdeck/surfaces/serve/__init__.py +7 -0
  96. agentdeck/surfaces/serve/app.py +71 -0
  97. agentdeck/surfaces/serve/compat.py +212 -0
  98. agentdeck/surfaces/serve/workflows.py +69 -0
  99. agentdeck/testing.py +364 -0
  100. agentdeck_sdk-3.1.0.dist-info/METADATA +222 -0
  101. agentdeck_sdk-3.1.0.dist-info/RECORD +104 -0
  102. agentdeck_sdk-3.1.0.dist-info/WHEEL +4 -0
  103. agentdeck_sdk-3.1.0.dist-info/entry_points.txt +3 -0
  104. agentdeck_sdk-3.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,410 @@
1
+ """The langgraph engine: ``EnginePort`` over a compiled ``StateGraph``.
2
+
3
+ ``spec.native`` is an *uncompiled* ``StateGraph`` — this adapter compiles it itself, with
4
+ its own checkpointer, and caches the result per invocable name (ADR-D5: an engine's
5
+ checkpointer is its own working memory, never shared with or read by an outer ring), so
6
+ nothing outside this directory ever sees a ``StateGraph``, a checkpointer, or a
7
+ ``thread_id``'s raw graph state. ``astream(..., stream_mode=["updates", "custom", "values"])``
8
+ maps onto the payloads this adapter yields: a ``{node: patch}`` update becomes
9
+ ``node.updated`` (an update langgraph reports as ``None``, which is what a node that returned
10
+ nothing looks like, becomes an empty patch), a ``{"__interrupt__": (...)}`` update becomes
11
+ ``run.interrupted`` and ends the stream (the graph suspends there; resuming re-enters the same
12
+ ``astream`` call with a ``Command(resume=value)``), a ``get_stream_writer()`` write becomes one
13
+ namespaced ``custom`` event, and the stream simply ending means the graph reached ``END``.
14
+
15
+ Both ends of a run are the graph's state: a ``DataBlock`` in is the initial state as posted,
16
+ and the final state leaves as a ``DataBlock`` on ``run.completed`` — structured going in,
17
+ structured coming out. Text in keeps the single ``{"input": text}`` channel. That final state
18
+ is the last ``values`` chunk rather than a checkpoint read, so a graph compiled without a
19
+ checkpointer still reports one.
20
+
21
+ The ``StateGraph``'s schema must be a ``TypedDict`` (or pydantic model), never a bare
22
+ ``dict``: langgraph treats a bare ``dict`` as one opaque channel, so a node's return
23
+ replaces the *entire* state instead of merging into it, which would silently break the
24
+ shallow-merge every ``node.updated`` promises its readers.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ import json
31
+ from collections.abc import Mapping
32
+ from contextlib import aclosing, nullcontext
33
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
34
+
35
+ from langgraph.checkpoint.memory import MemorySaver
36
+ from langgraph.graph.state import StateGraph
37
+ from langgraph.types import Command
38
+ from pydantic import BaseModel
39
+
40
+ from agentdeck.adapters.engines.langgraph.checkpointer import resolve_checkpointer
41
+ from agentdeck.core.content import DataBlock, TextBlock
42
+ from agentdeck.core.events import Custom, NodeUpdated, RunCompleted, RunInterrupted, Usage
43
+ from agentdeck.core.ports import EnginePort
44
+ from agentdeck.errors import ConfigError
45
+
46
+ if TYPE_CHECKING:
47
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
48
+ from contextlib import AbstractAsyncContextManager
49
+
50
+ from langchain_core.runnables import RunnableConfig
51
+ from langgraph.checkpoint.base import BaseCheckpointSaver
52
+ from langgraph.graph.state import CompiledStateGraph
53
+ from langgraph.types import StreamMode
54
+
55
+ from agentdeck.core.content import Input
56
+ from agentdeck.core.context import RunContext
57
+ from agentdeck.core.events import Event, KnownPayload
58
+ from agentdeck.core.invocable import InvocableSpec
59
+
60
+ _INTERRUPT_KEY = "__interrupt__"
61
+ _KNOWN_REASONS = frozenset({"human", "pause", "approval"})
62
+
63
+ DURABLE_KEY = "durable"
64
+ """``spec.metadata[DURABLE_KEY]``: whether this workflow's state must outlive the process.
65
+
66
+ Absent — a spec built in code that never said — leaves the engine's own default checkpointer
67
+ in place, which is what a caller wiring ``LangGraphEngine()`` by hand already gets. ``True``
68
+ is what makes the configured (sqlite/postgres) checkpointer be resolved *at all*, and only at
69
+ the first durable run, so the ``[durability]`` extra stays optional for a project that only
70
+ chats."""
71
+
72
+ STREAM_CONFIGURABLE_KEY = "agentdeck_stream"
73
+ """``config["configurable"][STREAM_CONFIGURABLE_KEY]``: this run's nodes may stream.
74
+
75
+ A node that drives an agent of its own checks this before using the SDK's streaming API, so
76
+ without it a nested agent's text deltas never reach the custom stream. Always on here, unlike
77
+ v1's opt-in: one run produces one canonical stream, and what a consumer does with it is not
78
+ the engine's business."""
79
+
80
+ REPORTER_KEY = "reporter"
81
+ """Where a node finds this run's ``Reporter``: ``config["configurable"]["reporter"]``.
82
+
83
+ langgraph's own injection channel, used rather than a channel of our own: a node that declares
84
+ a ``config: RunnableConfig`` parameter reaches the reporter there, so reporting costs the graph
85
+ schema nothing and a workflow that never reports is written exactly as before. Non-scalar
86
+ ``configurable`` values are excluded from checkpoint metadata by langgraph itself, so a durable
87
+ graph does not try to serialize it.
88
+
89
+ Distinct from ``STREAM_WRITE`` below on purpose: a ``get_stream_writer()`` payload is whatever a
90
+ node felt like writing, so it travels as a namespaced ``custom``, while status and progress are
91
+ canonical kinds every consumer already understands — D10's promotion, taken.
92
+ """
93
+ # A list, not a tuple: langgraph switches to ``(mode, chunk)`` chunks on a list specifically,
94
+ # and a tuple of the same modes streams the bare single-mode shape instead.
95
+ _STREAM_MODES: list[StreamMode] = ["updates", "custom", "values"]
96
+
97
+ # A ``get_stream_writer()`` write, which no kind describes: the graph author chose the value,
98
+ # so it is namespaced ``custom`` rather than a minted kind (D10). Wrapped under one key
99
+ # because a write is any JSON value and ``Custom.data`` is an object.
100
+ STREAM_WRITE = "langgraph.stream_write"
101
+ STREAM_WRITE_KEY = "value"
102
+
103
+
104
+ class LangGraphEngine(EnginePort):
105
+ """Plays ``spec.native`` (an uncompiled ``StateGraph``) through ``astream``.
106
+
107
+ ``checkpointer`` is what a non-durable graph compiles around — a fresh in-memory one by
108
+ default, never ``resolve_checkpointer("memory")``'s shared instance, because two engines
109
+ must not silently see each other's threads. ``durable_checkpoint`` is the
110
+ ``(backend, url)`` a ``durable`` workflow gets instead, resolved from settings at the
111
+ composition root but built here, lazily, at the first durable run: naming a backend must
112
+ not cost a project that only chats the ``[durability]`` extra.
113
+
114
+ ``workspace`` is the scope a run's nodes execute inside — injected, because a sandbox is
115
+ a capability rather than an engine concern, and unset for a project whose nodes need none.
116
+ """
117
+
118
+ engine: ClassVar[str] = "langgraph"
119
+
120
+ def __init__(
121
+ self,
122
+ checkpointer: BaseCheckpointSaver | None = None,
123
+ *,
124
+ durable_checkpoint: tuple[str, str] | None = None,
125
+ workspace: Callable[[], AbstractAsyncContextManager[Any]] | None = None,
126
+ ) -> None:
127
+ self._checkpointer = checkpointer or MemorySaver()
128
+ self._durable_checkpoint = durable_checkpoint
129
+ self._workspace = workspace
130
+ self._compiled: dict[str, CompiledStateGraph[Any, Any, Any, Any]] = {}
131
+
132
+ async def start(
133
+ self,
134
+ spec: InvocableSpec,
135
+ input: Input,
136
+ history: Sequence[Event],
137
+ ctx: RunContext,
138
+ ) -> AsyncGenerator[KnownPayload, None]:
139
+ # aclosing at every delegation: closing an async generator unwinds its own frame only,
140
+ # so an inner one iterated with a bare `async for` is abandoned to the GC — which
141
+ # finalizes it in a fresh context, where a ContextVar reset (the workspace scope
142
+ # ``_drive`` opens) raises instead of releasing.
143
+ async with aclosing(self._drive(spec, _to_graph_input(input), self._thread_id(ctx), ctx)) as stream:
144
+ async for payload in stream:
145
+ yield payload
146
+
147
+ async def resume(
148
+ self,
149
+ spec: InvocableSpec,
150
+ thread_id: str,
151
+ value: Any,
152
+ ctx: RunContext,
153
+ ) -> AsyncGenerator[KnownPayload, None]:
154
+ async with aclosing(self._drive(spec, Command(resume=value), thread_id, ctx)) as stream:
155
+ async for payload in stream:
156
+ yield payload
157
+
158
+ def _thread_id(self, ctx: RunContext) -> str:
159
+ """Which langgraph thread this run plays on: the session's, or its own.
160
+
161
+ A caller that names the thread keeps resuming it — ``POST /workflows/X?thread_id=t``
162
+ then ``POST /workflows/X/t/resume`` is two runs on one thread, which ``ctx.run_id``
163
+ could not express. A run with no session falls back to its own id, which a resume
164
+ names back via the ``RunInterrupted`` it got.
165
+ """
166
+ return ctx.session_id or ctx.run_id
167
+
168
+ async def _drive(
169
+ self,
170
+ spec: InvocableSpec,
171
+ graph_input: Any,
172
+ thread_id: str,
173
+ ctx: RunContext,
174
+ ) -> AsyncGenerator[KnownPayload, None]:
175
+ """Play ``graph_input`` on ``spec``'s graph, inside whatever scope its nodes need.
176
+
177
+ The one place the config is built, so ``start`` and ``resume`` hand a node the same
178
+ reporter and the same streaming permission without either of them mentioning it.
179
+ """
180
+ durable = spec.metadata.get(DURABLE_KEY)
181
+ if durable is True and ctx.session_id is None:
182
+ # A durable graph loads and persists its state by thread, so running one under a
183
+ # thread nobody can name back is a lost run.
184
+ raise ValueError(
185
+ f"{spec.name} is durable=True; a thread_id is required to load/persist checkpointed state.",
186
+ )
187
+ config: RunnableConfig = {
188
+ "configurable": {
189
+ "thread_id": thread_id,
190
+ REPORTER_KEY: ctx.reporter,
191
+ STREAM_CONFIGURABLE_KEY: True,
192
+ }
193
+ }
194
+ # The workspace is a ContextVar scope, so the stream it wraps has to be closed from
195
+ # inside it — an abandoned generator releases it from the wrong context.
196
+ scope = self._workspace() if self._workspace is not None else nullcontext(None)
197
+ async with scope, aclosing(self._play(self._graph_for(spec), graph_input, config, ctx)) as stream:
198
+ async for payload in stream:
199
+ if isinstance(payload, RunInterrupted) and durable is False:
200
+ raise ConfigError(
201
+ f"{spec.name} called interrupt() but is durable=False: with no checkpointer "
202
+ "the paused run cannot be resumed. Set `durable = True` on the workflow.",
203
+ )
204
+ yield payload
205
+
206
+ def _graph_for(self, spec: InvocableSpec) -> CompiledStateGraph[Any, Any, Any, Any]:
207
+ compiled = self._compiled.get(spec.name)
208
+ if compiled is None:
209
+ if not isinstance(spec.native, StateGraph):
210
+ raise ConfigError(
211
+ f"{spec.name!r} has no langgraph StateGraph: expected native=StateGraph, got {type(spec.native)}"
212
+ )
213
+ compiled = spec.native.compile(checkpointer=self._checkpointer_for(spec))
214
+ self._compiled[spec.name] = compiled
215
+ return compiled
216
+
217
+ def _checkpointer_for(self, spec: InvocableSpec) -> BaseCheckpointSaver | None:
218
+ """This graph's checkpointer. Three answers, because ``durable`` has three states.
219
+
220
+ Declared ``False`` means **no checkpointer at all** — not an in-memory one. A saver
221
+ keyed by thread would make a second run on a thread resume the first's state instead
222
+ of starting fresh, which is the opposite of what a workflow declaring itself
223
+ non-durable asked for.
224
+
225
+ Absent is not the same as ``False``: a spec built in code never said, so it keeps the
226
+ engine's own default (see ``DURABLE_KEY``), which is what a hand-wired
227
+ ``LangGraphEngine()`` already gets and what lets such a graph interrupt at all.
228
+
229
+ The configured saver is resolved here and not in ``__init__``: ``sqlite``/``postgres``
230
+ savers live in the ``[durability]`` extra, so a composition root that merely names a
231
+ backend must not import one until a workflow that needs it actually runs.
232
+ """
233
+ durable = spec.metadata.get(DURABLE_KEY)
234
+ if durable is False:
235
+ return None
236
+ if durable is True and self._durable_checkpoint is not None:
237
+ return resolve_checkpointer(*self._durable_checkpoint)
238
+ return self._checkpointer
239
+
240
+ async def _play(
241
+ self,
242
+ graph: CompiledStateGraph[Any, Any, Any, Any],
243
+ graph_input: Any,
244
+ config: RunnableConfig,
245
+ ctx: RunContext,
246
+ ) -> AsyncGenerator[KnownPayload, None]:
247
+ thread_id = config["configurable"]["thread_id"]
248
+ # A values chunk always precedes the first update (the initial state), so the empty
249
+ # default is only ever the state of a graph that ran nothing at all.
250
+ state: Any = {}
251
+ # astream's stub only declares the single-mode shape; multi-mode yields (mode, chunk) tuples.
252
+ stream = cast(
253
+ "AsyncIterator[tuple[str, Any]]",
254
+ # The run context travels as langgraph's own runtime context, which is what reaches
255
+ # a node: `authoring.graphs` compiles a node declaring ``Context[...]`` into one
256
+ # declaring ``runtime``, and reads the carrier back off ``runtime.context`` there.
257
+ # Distinct from ``configurable`` on purpose — langgraph draws the same
258
+ # state-vs-runtime-context line, and application data does not belong beside a
259
+ # thread id. Resuming goes through the same call, so a resumed run is resupplied.
260
+ graph.astream(graph_input, config=config, context=ctx, stream_mode=_STREAM_MODES),
261
+ )
262
+ async for mode, chunk in stream:
263
+ if mode == "values":
264
+ state = chunk # tracked for the terminal event, not an event of its own
265
+ elif mode == "custom":
266
+ yield Custom(name=STREAM_WRITE, data={STREAM_WRITE_KEY: self._as_json(chunk)})
267
+ else:
268
+ interrupted = chunk.get(_INTERRUPT_KEY)
269
+ if interrupted is not None:
270
+ pause = self._interrupted(interrupted[0], thread_id)
271
+ # Let langgraph finish before reporting the pause. Returning here instead
272
+ # abandons ``astream`` mid-flight, and with an *async* checkpointer the
273
+ # pause is then not written: the resume finds the checkpoint from before
274
+ # the interrupt, re-runs the node and interrupts all over again, so a
275
+ # durable workflow silently never resumes. The in-memory saver hides it,
276
+ # having nothing to await. Draining first also orders the two records the
277
+ # only safe way round — the engine's checkpoint is durable before the
278
+ # canonical log says the run is waiting on a human.
279
+ #
280
+ # A sibling branch in the same superstep that is still running when the
281
+ # interrupt is detected finishes somewhere in this drain — langgraph
282
+ # reports the pause as soon as one branch asks for it, not once the whole
283
+ # step is done. Forwarding what the drain turns up (#122) means such a
284
+ # branch's report is not silently thrown away with the rest of the drain:
285
+ # its checkpoint write already landed (that is what draining guarantees),
286
+ # so the canonical log should say so too. The pause itself is still last —
287
+ # every other event report the wire renders is well-defined for a run that
288
+ # then went on to finish; the pause is the one report a client must be able
289
+ # to treat as "nothing more is coming this call", so it never precedes
290
+ # events that actually happened.
291
+ async for payload in self._drain_after_interrupt(stream):
292
+ yield payload
293
+ yield pause
294
+ return # the graph suspended; its terminal event arrives on resume
295
+ for node, patch in chunk.items():
296
+ yield NodeUpdated(node=node, state_patch=self._as_patch(patch, node))
297
+ yield RunCompleted(
298
+ output=[DataBlock(data=self._as_data(state, "final state"))],
299
+ usage=Usage(input_tokens=0, output_tokens=0),
300
+ )
301
+
302
+ async def _drain_after_interrupt(
303
+ self, stream: AsyncIterator[tuple[str, Any]]
304
+ ) -> AsyncGenerator[KnownPayload, None]:
305
+ """The rest of ``stream`` once one branch has already asked to pause.
306
+
307
+ Reported the same way the main loop would report it — ``NodeUpdated`` per node,
308
+ ``Custom`` per stream write — except a second interrupt in the same superstep is not
309
+ one this engine acts on: ``_play`` already reports only the first (``_interrupted``
310
+ reads ``interrupt[0]``), so two branches interrupting at once is out of scope here
311
+ rather than silently mis-paired with the wrong pause. A trailing ``values`` chunk is
312
+ the graph's state at drain time, not an event any consumer reads — the run that
313
+ produced it never reaches ``RunCompleted``, so there is nothing to attach it to.
314
+ """
315
+ async for mode, chunk in stream:
316
+ if mode == "values":
317
+ continue
318
+ if mode == "custom":
319
+ yield Custom(name=STREAM_WRITE, data={STREAM_WRITE_KEY: self._as_json(chunk)})
320
+ continue
321
+ for node, patch in chunk.items():
322
+ if node == _INTERRUPT_KEY:
323
+ continue
324
+ yield NodeUpdated(node=node, state_patch=self._as_patch(patch, node))
325
+
326
+ def _interrupted(self, interrupt: Any, thread_id: str) -> RunInterrupted:
327
+ value = interrupt.value
328
+ reason = value.get("reason") if isinstance(value, Mapping) else None
329
+ return RunInterrupted(
330
+ interrupt_id=str(interrupt.id),
331
+ reason=reason if reason in _KNOWN_REASONS else "human",
332
+ payload=self._as_data(value, "interrupt"),
333
+ thread_id=thread_id,
334
+ )
335
+
336
+ def _as_patch(self, patch: Any, node: str) -> dict[str, Any]:
337
+ """One node's state update, where *no* update is a legitimate answer.
338
+
339
+ langgraph reports a node that changed nothing — the side-effect-only node that logs or
340
+ notifies and returns ``None``, and equally one returning ``{}`` — as ``{node: None}``.
341
+ An absent patch is not a malformed one, so it is the empty patch rather than a refusal.
342
+ """
343
+ return {} if patch is None else self._as_data(patch, node)
344
+
345
+ def _as_data(self, value: Any, source: str) -> dict[str, Any]:
346
+ """One node update / interrupt payload / graph state as the JSON object an event
347
+ carries and a store writes.
348
+
349
+ Crude on purpose (M0): the value must be a plain mapping to round-trip through the
350
+ event schema's ``dict[str, Any]`` fields, so a graph returning something else is
351
+ refused rather than silently misserialized.
352
+ """
353
+ if not isinstance(value, Mapping):
354
+ raise ConfigError(f"langgraph engine (M0) only supports dict-shaped {source} values, got {type(value)}")
355
+ return self._as_json(dict(value))
356
+
357
+ def _as_json(self, value: Any) -> Any:
358
+ """``value`` as plain JSON data.
359
+
360
+ A leaf JSON cannot hold becomes its ``str()``, non-finite floats included
361
+ (``parse_constant`` catches the ``NaN``/``Infinity`` tokens that ``default`` never
362
+ sees, because those leaves *are* floats): the same fidelity ceiling the previous
363
+ ``str(dict(values))`` had for the whole state, so a graph that completed before does
364
+ not start failing here.
365
+ """
366
+ return json.loads(json.dumps(value, default=self._leaf), parse_constant=str)
367
+
368
+ def _leaf(self, value: Any) -> Any:
369
+ """A single value JSON cannot carry, as data.
370
+
371
+ A pydantic model or a dataclass is a state channel a node legitimately returns, so it
372
+ reaches a consumer as the object it is rather than as its ``repr``; ``__dict__`` covers
373
+ the plain object a node built by hand. Everything else is ``str()`` — the ceiling.
374
+ """
375
+ if isinstance(value, BaseModel):
376
+ return value.model_dump()
377
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
378
+ return dataclasses.asdict(value)
379
+ if hasattr(value, "__dict__"):
380
+ return value.__dict__
381
+ return str(value)
382
+
383
+
384
+ def _to_graph_input(input: Input) -> dict[str, Any]:
385
+ """A graph's input is its state, so a single ``DataBlock`` *is* that state; text keeps
386
+ the one-channel shape (``{"input": text}``) a text-in workflow was written against."""
387
+ data = [block for block in input if isinstance(block, DataBlock)]
388
+ if data:
389
+ if len(input) != 1:
390
+ raise ConfigError("langgraph engine: a state-shaped input is one data block and nothing else")
391
+ state = data[0].data
392
+ if not isinstance(state, dict):
393
+ raise ConfigError(f"langgraph engine: a data block input must be a JSON object, got {type(state)}")
394
+ return dict(state)
395
+ # Images/resources are a follow-up, not a silent drop — better to raise now than feed a
396
+ # node a blank string.
397
+ texts = [block.text for block in input if isinstance(block, TextBlock)]
398
+ if len(texts) != len(input):
399
+ raise ConfigError("langgraph engine only supports text or data input blocks")
400
+ return {"input": "\n".join(texts)}
401
+
402
+
403
+ __all__ = [
404
+ "DURABLE_KEY",
405
+ "REPORTER_KEY",
406
+ "STREAM_CONFIGURABLE_KEY",
407
+ "STREAM_WRITE",
408
+ "STREAM_WRITE_KEY",
409
+ "LangGraphEngine",
410
+ ]
@@ -0,0 +1,9 @@
1
+ """The openai-agents engine adapter: ``EnginePort`` over ``agents.Runner``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentdeck.adapters.engines.openai_agents.engine import OpenAIAgentsEngine
6
+ from agentdeck.adapters.engines.openai_agents.runconfig import RunSettings
7
+ from agentdeck.adapters.engines.openai_agents.sessions import ExecutionStore, SessionFactory
8
+
9
+ __all__ = ["ExecutionStore", "OpenAIAgentsEngine", "RunSettings", "SessionFactory"]