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.
- agentdeck/README.md +50 -0
- agentdeck/__init__.py +51 -0
- agentdeck/adapters/__init__.py +5 -0
- agentdeck/adapters/control/__init__.py +1 -0
- agentdeck/adapters/control/memory/__init__.py +5 -0
- agentdeck/adapters/control/memory/port.py +25 -0
- agentdeck/adapters/control/sqlite/__init__.py +5 -0
- agentdeck/adapters/control/sqlite/port.py +111 -0
- agentdeck/adapters/engines/__init__.py +1 -0
- agentdeck/adapters/engines/langgraph/__init__.py +8 -0
- agentdeck/adapters/engines/langgraph/checkpointer.py +205 -0
- agentdeck/adapters/engines/langgraph/engine.py +410 -0
- agentdeck/adapters/engines/openai_agents/__init__.py +9 -0
- agentdeck/adapters/engines/openai_agents/engine.py +321 -0
- agentdeck/adapters/engines/openai_agents/reconcile.py +178 -0
- agentdeck/adapters/engines/openai_agents/runconfig.py +118 -0
- agentdeck/adapters/engines/openai_agents/sessions.py +100 -0
- agentdeck/adapters/engines/openai_agents/translate.py +124 -0
- agentdeck/adapters/engines/stub/__init__.py +5 -0
- agentdeck/adapters/engines/stub/engine.py +100 -0
- agentdeck/adapters/stores/__init__.py +1 -0
- agentdeck/adapters/stores/memory/__init__.py +5 -0
- agentdeck/adapters/stores/memory/store.py +148 -0
- agentdeck/adapters/stores/postgres/__init__.py +5 -0
- agentdeck/adapters/stores/postgres/store.py +374 -0
- agentdeck/adapters/stores/redis/__init__.py +5 -0
- agentdeck/adapters/stores/redis/store.py +359 -0
- agentdeck/adapters/stores/sqlite/__init__.py +5 -0
- agentdeck/adapters/stores/sqlite/store.py +338 -0
- agentdeck/adapters/telemetry/__init__.py +1 -0
- agentdeck/adapters/telemetry/langfuse/__init__.py +18 -0
- agentdeck/adapters/telemetry/langfuse/client.py +180 -0
- agentdeck/adapters/telemetry/langfuse/sink.py +366 -0
- agentdeck/adapters/telemetry/langfuse/trace.py +88 -0
- agentdeck/adapters/tools/__init__.py +1 -0
- agentdeck/adapters/tools/mcp/__init__.py +18 -0
- agentdeck/adapters/tools/mcp/lifecycle.py +178 -0
- agentdeck/adapters/tools/mcp/source.py +47 -0
- agentdeck/adapters/tools/mcp/transport.py +234 -0
- agentdeck/adapters/tools/mcp/wiring.py +63 -0
- agentdeck/authoring/__init__.py +22 -0
- agentdeck/authoring/agent.py +169 -0
- agentdeck/authoring/compile.py +250 -0
- agentdeck/authoring/graphs.py +145 -0
- agentdeck/authoring/hooks.py +117 -0
- agentdeck/authoring/injection.py +233 -0
- agentdeck/authoring/instructions.py +80 -0
- agentdeck/authoring/interrupts.py +37 -0
- agentdeck/authoring/nodes.py +140 -0
- agentdeck/authoring/runners/__init__.py +6 -0
- agentdeck/authoring/runners/agent.py +171 -0
- agentdeck/authoring/runners/workflow.py +99 -0
- agentdeck/authoring/skills.py +56 -0
- agentdeck/authoring/state.py +44 -0
- agentdeck/authoring/timers.py +44 -0
- agentdeck/authoring/tools.py +147 -0
- agentdeck/authoring/web_search.py +43 -0
- agentdeck/authoring/workflow.py +266 -0
- agentdeck/cli.py +56 -0
- agentdeck/composition.py +213 -0
- agentdeck/core/__init__.py +110 -0
- agentdeck/core/base.py +47 -0
- agentdeck/core/content.py +166 -0
- agentdeck/core/context.py +136 -0
- agentdeck/core/control.py +150 -0
- agentdeck/core/events.py +468 -0
- agentdeck/core/invocable.py +38 -0
- agentdeck/core/ports/__init__.py +26 -0
- agentdeck/core/ports/control.py +35 -0
- agentdeck/core/ports/engine.py +66 -0
- agentdeck/core/ports/sink.py +53 -0
- agentdeck/core/ports/store.py +170 -0
- agentdeck/core/ports/tools.py +57 -0
- agentdeck/core/reporting.py +75 -0
- agentdeck/core/status.py +75 -0
- agentdeck/deck.py +894 -0
- agentdeck/errors.py +67 -0
- agentdeck/mcp.py +82 -0
- agentdeck/observers.py +111 -0
- agentdeck/py.typed +0 -0
- agentdeck/runtime/__init__.py +1 -0
- agentdeck/runtime/capture.py +32 -0
- agentdeck/runtime/config.default.yaml +38 -0
- agentdeck/runtime/discovery.py +175 -0
- agentdeck/runtime/dispatch.py +440 -0
- agentdeck/runtime/registry.py +173 -0
- agentdeck/runtime/service.py +671 -0
- agentdeck/runtime/settings.py +568 -0
- agentdeck/serve.py +330 -0
- agentdeck/skills/__init__.py +114 -0
- agentdeck/skills/bundle.py +65 -0
- agentdeck/surfaces/__init__.py +4 -0
- agentdeck/surfaces/cli/__init__.py +7 -0
- agentdeck/surfaces/cli/chat.py +88 -0
- agentdeck/surfaces/serve/__init__.py +7 -0
- agentdeck/surfaces/serve/app.py +71 -0
- agentdeck/surfaces/serve/compat.py +212 -0
- agentdeck/surfaces/serve/workflows.py +69 -0
- agentdeck/testing.py +364 -0
- agentdeck_sdk-3.1.0.dist-info/METADATA +222 -0
- agentdeck_sdk-3.1.0.dist-info/RECORD +104 -0
- agentdeck_sdk-3.1.0.dist-info/WHEEL +4 -0
- agentdeck_sdk-3.1.0.dist-info/entry_points.txt +3 -0
- 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"]
|