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
agentdeck/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # `agentdeck/`
2
+
3
+ Declarative layer over the [OpenAI Agents SDK](https://github.com/openai/openai-agents-python)
4
+ and [LangGraph](https://langchain-ai.github.io/langgraph/).
5
+
6
+ agentdeck owns **configuration** — settings, tools and MCP servers, skill disclosure, graph
7
+ compilation, plug-in discovery. Execution stays in the SDK / LangGraph.
8
+
9
+ ```text
10
+ agentdeck/
11
+ core/ # event schema and ports — stdlib + pydantic only
12
+ runtime/ # settings, plugin discovery, the Runtime's own primitives
13
+ authoring/ # Agent/Workflow: the declarative construction API, compiles to InvocableSpec
14
+ skills/ # Skills: SKILL.md discovery, validation, disclosure text
15
+ adapters/ # engines (openai-agents, langgraph), event stores, control ports, tool sources
16
+ surfaces/ # thin readers over the canonical event stream (HTTP compat, CLI)
17
+ deck.py # Deck: the composition root — build a catalog, open it, run turns on it
18
+ mcp.py # MCP: .mcp.json parsing and validation
19
+ serve.py # the FastAPI app agentdeck-serve runs
20
+ ```
21
+
22
+ `Deck` is the one class application code needs directly — see the top-level
23
+ [README](../README.md) for how to use it, and [`docs-site/`](../docs-site/) for the full guide.
24
+ The rest of this package is composition plumbing `Deck` wires together; nothing else here is
25
+ meant to be imported on its own except `agentdeck.authoring` (`Agent`, `Workflow`, and the
26
+ node/declaration types they compile from).
27
+
28
+ ## Plug-in discovery
29
+
30
+ `agentdeck.runtime.registry.PluginRegistry` walks `<package>/<type_dir>/<bundle>/<module>.py` and
31
+ indexes every module-level *instance* of a base class (`Agent`, `Workflow`) — not subclasses; an
32
+ `Agent`/`Workflow` is a value, not something to subclass. `Deck.from_project()` builds one
33
+ registry each for `agents/` and `workflows/`. A pre-0.3 project dir without the `agents/`/
34
+ `workflows/` type subdirectory raises a `ConfigError` pointing at the current layout instead of
35
+ silently discovering nothing.
36
+
37
+ ## Settings
38
+
39
+ Layered Pydantic-Settings models. See [`runtime/settings.py`](runtime/settings.py) for
40
+ definitions, or the generated `docs-site/content/reference/settings.mdx` for every
41
+ `AGENTDECK_*`/`OPENAI_*`/`TAVILY_*` env var.
42
+
43
+ ```python
44
+ from agentdeck.runtime.settings import get_settings
45
+
46
+ s = get_settings() # cached
47
+ s.openai.model
48
+ s.runner.max_turns
49
+ s.checkpoint.url # e.g. "sqlite://.agentdeck/checkpoints.sqlite3" — the scheme names the backend
50
+ ```
agentdeck/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """agentdeck — declarative framework over the OpenAI Agents SDK and LangGraph.
2
+
3
+ Owns *configuration* (settings, capabilities, runner glue, graph compilation,
4
+ plug-in discovery) so the underlying engines own *execution*.
5
+ :class:`agentdeck.Deck` is the composition root — either constructed directly
6
+ from :class:`agentdeck.Agent`/:class:`agentdeck.Workflow` declarations, or
7
+ discovered from a project directory (``Deck.from_project()``).
8
+ """
9
+
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as _version
12
+
13
+ from agentdeck.authoring import Agent, Workflow
14
+ from agentdeck.core.context import Context
15
+ from agentdeck.deck import Deck, TurnResult
16
+ from agentdeck.errors import (
17
+ AgentdeckError,
18
+ ConfigError,
19
+ ContextTypeError,
20
+ NotFoundError,
21
+ SessionBusyError,
22
+ SkillError,
23
+ StoreError,
24
+ )
25
+
26
+ try:
27
+ # The *distribution* is `agentdeck-sdk`; the import package is `agentdeck`. They differ
28
+ # because PyPI refuses `agentdeck` as too similar to the squatted `agent-deck` placeholder.
29
+ # Passing the import name here returns nothing and falls through to "0+unknown" — a silently
30
+ # wrong version, which is the exact failure #176 added `__version__` to prevent.
31
+ __version__ = _version("agentdeck-sdk")
32
+ except PackageNotFoundError:
33
+ # Running from a source checkout with no installed distribution (e.g. no `pip install -e .`
34
+ # yet) — a version string is still expected of every attribute lookup, not a raise.
35
+ __version__ = "0+unknown"
36
+
37
+ __all__ = [
38
+ "Agent",
39
+ "AgentdeckError",
40
+ "ConfigError",
41
+ "Context",
42
+ "ContextTypeError",
43
+ "Deck",
44
+ "NotFoundError",
45
+ "SessionBusyError",
46
+ "SkillError",
47
+ "StoreError",
48
+ "TurnResult",
49
+ "Workflow",
50
+ "__version__",
51
+ ]
@@ -0,0 +1,5 @@
1
+ """Ring 2 — one directory per external system, each behind a core port.
2
+
3
+ Nothing outside an adapter directory may import that system, and no adapter imports
4
+ another: deleting one must break nothing but itself.
5
+ """
@@ -0,0 +1 @@
1
+ """Run control — implementations of ``ControlPort``."""
@@ -0,0 +1,5 @@
1
+ """``ControlPort`` in a dict."""
2
+
3
+ from agentdeck.adapters.control.memory.port import MemoryControlPort
4
+
5
+ __all__ = ["MemoryControlPort"]
@@ -0,0 +1,25 @@
1
+ """In-process ``ControlPort``: a dict keyed by ``run_id``. Dev and single-process tests
2
+ only — process exit loses every pending signal, same posture as ``stores.memory``.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from agentdeck.core.control import ControlSignal, Signal
8
+ from agentdeck.core.ports.control import ControlPort
9
+
10
+
11
+ class MemoryControlPort(ControlPort):
12
+ """One signal per run, held in memory. Overwriting a run's signal is intentional: only
13
+ the latest one matters, which is also how ``RESUME`` lifts a pending ``PAUSE``."""
14
+
15
+ def __init__(self) -> None:
16
+ self._signals: dict[str, ControlSignal] = {}
17
+
18
+ async def signal(self, run_id: str, sig: Signal, reason: str | None = None) -> None:
19
+ self._signals[run_id] = ControlSignal(verb=sig, reason=reason)
20
+
21
+ async def poll(self, run_id: str) -> ControlSignal | None:
22
+ return self._signals.get(run_id)
23
+
24
+
25
+ __all__ = ["MemoryControlPort"]
@@ -0,0 +1,5 @@
1
+ """``ControlPort`` in SQLite."""
2
+
3
+ from agentdeck.adapters.control.sqlite.port import SqliteControlPort
4
+
5
+ __all__ = ["SqliteControlPort"]
@@ -0,0 +1,111 @@
1
+ """``ControlPort`` in SQLite: one row per ``run_id``, durable enough that a second OS
2
+ process — opening the same file, never sharing a connection or any Python state — can
3
+ signal a run it never held a reference to. This is what makes cross-process cancel real
4
+ instead of theoretical; Redis is the multi-worker upgrade, deferred to Story 3.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import sqlite3
11
+ from contextlib import suppress
12
+ from functools import partial
13
+ from typing import TYPE_CHECKING
14
+
15
+ from agentdeck.core.control import ControlSignal, Signal
16
+ from agentdeck.core.ports.control import ControlPort
17
+ from agentdeck.errors import StoreError
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+
23
+ # ponytail: the signals table grows one row per signaled run, never pruned — add a
24
+ # prune-on-terminal or TTL sweep when signal volume matters.
25
+ _SCHEMA = "CREATE TABLE IF NOT EXISTS signals (run_id TEXT PRIMARY KEY, signal TEXT NOT NULL, reason TEXT);"
26
+
27
+ # A file written before signals carried a reason has no such column, and reading one is how
28
+ # the caller finds out. Added in place instead: a pending cancel is state worth keeping.
29
+ _ADD_REASON = "ALTER TABLE signals ADD COLUMN reason TEXT"
30
+
31
+ # Same as the event log's: long enough to wait a peer's write out, short enough that a wedged
32
+ # holder surfaces as an error rather than a hang.
33
+ _BUSY_TIMEOUT_MS = 5_000
34
+
35
+
36
+ def _connect(db_path: str) -> sqlite3.Connection:
37
+ """Open the signal table in WAL with an explicit busy timeout, so a run polling for a
38
+ signal is not blocked by another process writing one, and a cancel is not refused because
39
+ a poll happened to be reading.
40
+
41
+ The timeout is set first so the mode switch can wait a peer's transaction out, and the
42
+ switch is skipped when the file is already in WAL — re-asking is free there, but the
43
+ conversion itself needs an exclusive lock that a peer's open write denies outright. A
44
+ file that cannot be converted right now keeps the mode it has: slower under contention,
45
+ never wrong. ``:memory:`` reports ``memory`` and stays there.
46
+ """
47
+ try:
48
+ conn = sqlite3.connect(db_path, check_same_thread=False)
49
+ conn.execute(f"PRAGMA busy_timeout = {_BUSY_TIMEOUT_MS}")
50
+ if conn.execute("PRAGMA journal_mode").fetchone()[0] != "wal":
51
+ # ponytail: silent, like the event store's — log it if an operator ever has to
52
+ # find out why one process came up without WAL.
53
+ with suppress(sqlite3.OperationalError):
54
+ conn.execute("PRAGMA journal_mode = WAL")
55
+ conn.executescript(_SCHEMA)
56
+ if not any(row[1] == "reason" for row in conn.execute("PRAGMA table_info(signals)")):
57
+ conn.execute(_ADD_REASON)
58
+ conn.commit()
59
+ except sqlite3.Error as exc:
60
+ raise StoreError(f"cannot open the control signals at {db_path!r}: {exc}") from exc
61
+ return conn
62
+
63
+
64
+ class SqliteControlPort(ControlPort):
65
+ """One connection, serialized by a lock — same posture as ``stores.sqlite`` and for
66
+ the same reason: ``sqlite3`` is stdlib but not coroutine-safe. Failures reach the caller
67
+ as ``StoreError`` rather than as a ``sqlite3`` exception, and the same WAL caveats apply:
68
+ ``-wal``/``-shm`` files sit beside this database, and it belongs on local disk because
69
+ WAL is unreliable on network filesystems.
70
+ """
71
+
72
+ def __init__(self, db_path: str | Path = ":memory:") -> None:
73
+ self._conn = _connect(str(db_path))
74
+ self._lock = asyncio.Lock()
75
+
76
+ async def _run[T](self, work: Callable[[], T], op: str) -> T:
77
+ """Every statement goes through here — one caller at a time, off the event loop, and
78
+ no library exception escaping the port."""
79
+ async with self._lock:
80
+ try:
81
+ return await asyncio.to_thread(work)
82
+ except sqlite3.Error as exc:
83
+ raise StoreError(f"control signal {op} failed: {exc}") from exc
84
+
85
+ async def signal(self, run_id: str, sig: Signal, reason: str | None = None) -> None:
86
+ await self._run(partial(self._write, run_id, sig.value, reason), "signal")
87
+
88
+ async def poll(self, run_id: str) -> ControlSignal | None:
89
+ row = await self._run(partial(self._read, run_id), "poll")
90
+ return ControlSignal(verb=Signal(row[0]), reason=row[1]) if row is not None else None
91
+
92
+ def _write(self, run_id: str, sig: str, reason: str | None) -> None:
93
+ self._conn.execute(
94
+ "INSERT INTO signals (run_id, signal, reason) VALUES (?, ?, ?) "
95
+ "ON CONFLICT(run_id) DO UPDATE SET signal = excluded.signal, reason = excluded.reason",
96
+ (run_id, sig, reason),
97
+ )
98
+ self._conn.commit()
99
+
100
+ def _read(self, run_id: str) -> tuple[str, str | None] | None:
101
+ row = self._conn.execute("SELECT signal, reason FROM signals WHERE run_id = ?", (run_id,)).fetchone()
102
+ return (row[0], row[1]) if row else None
103
+
104
+ def close(self) -> None:
105
+ try:
106
+ self._conn.close()
107
+ except sqlite3.Error as exc:
108
+ raise StoreError(f"closing the control signals failed: {exc}") from exc
109
+
110
+
111
+ __all__ = ["SqliteControlPort"]
@@ -0,0 +1 @@
1
+ """Execution engines — implementations of ``EnginePort``, one directory per SDK."""
@@ -0,0 +1,8 @@
1
+ """The langgraph engine adapter: ``EnginePort`` over a compiled ``StateGraph``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentdeck.adapters.engines.langgraph.checkpointer import resolve_checkpointer
6
+ from agentdeck.adapters.engines.langgraph.engine import DURABLE_KEY, REPORTER_KEY, LangGraphEngine
7
+
8
+ __all__ = ["DURABLE_KEY", "REPORTER_KEY", "LangGraphEngine", "resolve_checkpointer"]
@@ -0,0 +1,205 @@
1
+ """Resolve a LangGraph checkpointer for the langgraph engine's runs.
2
+
3
+ Relocated from ``agentdeck.runtime.checkpointer``, which was written for v1's
4
+ ``BaseWorkflow`` durability but holds exactly the state a checkpointer engine must keep
5
+ private to its own adapter (ADR-D5: execution state belongs to the engine that produced
6
+ it, never shared or derived by an outer ring) — the same relationship ``sessions.py`` has
7
+ to the openai-agents adapter. ``agentdeck.authoring.compile.compile_workflow`` (a durable
8
+ ``Workflow``'s direct-call path) imports this module directly and translates
9
+ ``CheckpointSettings`` into the plain ``(backend, url)`` this function takes. ``memory``
10
+ ships with core ``langgraph`` and needs nothing extra;
11
+ ``sqlite`` / ``postgres`` live in the optional ``[durability]`` extra
12
+ (``langgraph-checkpoint-sqlite`` / ``langgraph-checkpoint-postgres``) and are imported
13
+ lazily, only when actually requested, with a clear install hint if the extra is missing.
14
+
15
+ Connection lifecycle: one saver per backend+url **per event loop**, so repeated calls
16
+ against the same file reuse the same connection instead of opening one per compile, without
17
+ handing a second loop a saver that is bound to the first (see ``_per_loop``).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import sqlite3
24
+ import threading
25
+ from functools import cache, partial
26
+ from typing import TYPE_CHECKING, Any, TypeVar
27
+ from weakref import WeakKeyDictionary
28
+
29
+ from agentdeck.errors import StoreError
30
+
31
+ if TYPE_CHECKING:
32
+ from asyncio import AbstractEventLoop
33
+ from collections.abc import Callable, Coroutine, MutableMapping
34
+
35
+ from langgraph.checkpoint.base import BaseCheckpointSaver
36
+
37
+ _DURABILITY_HINT = 'install the "durability" extra: pip install "agentdeck[durability]"'
38
+
39
+ _T = TypeVar("_T")
40
+
41
+ _savers: MutableMapping[AbstractEventLoop, dict[tuple[str, str], BaseCheckpointSaver]] = WeakKeyDictionary()
42
+
43
+
44
+ def _per_loop(backend: str, url: str, build: Callable[[], BaseCheckpointSaver]) -> BaseCheckpointSaver:
45
+ """Cache ``build``'s saver against the running event loop rather than the process.
46
+
47
+ The async sqlite and postgres savers hold asyncio primitives — a ``Lock``, and under it a
48
+ connection — that bind to the first loop to *contend* for them. A process-wide cache
49
+ therefore hands a second loop a saver the first one owns, and the second loop's first
50
+ concurrent access dies with "bound to a different event loop": fine for a server, which
51
+ is one loop for its lifetime, and broken for anything that runs more than one.
52
+
53
+ Resolved with no loop running (a script that compiles its graph before ``asyncio.run``),
54
+ nothing is cached: there is no loop to key on, and the saver will bind to whichever one
55
+ first uses it — so caching it is precisely how the same breakage would come back. That
56
+ costs one connection per resolution on a path that resolves once.
57
+
58
+ What the weak keying does *not* buy: a saver ends up referencing the loop it bound to
59
+ (through that same lock), so each entry keeps its own key alive and nothing is collected
60
+ until ``_savers`` itself is. A process that runs many loops in a row therefore accumulates
61
+ one connection and one aiosqlite thread per loop — the wrong half of a trade whose right
62
+ half is a saver that works on the second loop. Zero effect on a server. ponytail: bounding
63
+ it means closing the savers at loop shutdown, i.e. owning their lifecycle — worth doing
64
+ when something long-lived actually runs loops in a row.
65
+ """
66
+ try:
67
+ loop = asyncio.get_running_loop()
68
+ except RuntimeError:
69
+ return build()
70
+ per_loop = _savers.setdefault(loop, {})
71
+ key = (backend, url)
72
+ if key not in per_loop:
73
+ per_loop[key] = build()
74
+ return per_loop[key]
75
+
76
+
77
+ def _run_sync(coro: Coroutine[None, None, _T]) -> _T:
78
+ """Run ``coro`` to completion, whether or not an event loop is already running.
79
+
80
+ The engine may resolve a checkpointer lazily from *inside* an async ``start()`` call,
81
+ so plain ``asyncio.run`` would collide with the running loop. The one-shot bootstrap
82
+ connection (aiosqlite's async handshake) is cheap enough to hand to a throwaway
83
+ thread+loop in that case; all later query traffic runs on the caller's own loop.
84
+ """
85
+ try:
86
+ asyncio.get_running_loop()
87
+ except RuntimeError:
88
+ return asyncio.run(coro)
89
+ result: list[_T] = []
90
+ error: list[BaseException] = []
91
+
92
+ def _runner() -> None:
93
+ try:
94
+ result.append(asyncio.run(coro))
95
+ except BaseException as exc: # noqa: BLE001 — re-raised on the calling thread below
96
+ error.append(exc)
97
+
98
+ thread = threading.Thread(target=_runner, daemon=True)
99
+ thread.start()
100
+ thread.join()
101
+ if error:
102
+ raise error[0]
103
+ return result[0]
104
+
105
+
106
+ def resolve_checkpointer(backend: str, url: str = "") -> BaseCheckpointSaver:
107
+ """Build the checkpointer named by ``backend`` (``memory`` / ``sqlite`` / ``postgres``).
108
+
109
+ ``url`` is the sqlite file path or the Postgres DSN — primitives, not a settings
110
+ object, so this adapter takes core plus langgraph and nothing else. Raises
111
+ ``ValueError`` for an unknown backend, ``ImportError`` (with an install hint) when
112
+ ``sqlite``/``postgres`` is requested but the ``[durability]`` extra isn't installed,
113
+ and ``StoreError`` when the backend cannot open its connection at all, naming
114
+ ``AGENTDECK_CHECKPOINT`` — and, for sqlite, the resolved file path; a Postgres DSN is
115
+ not named, since it can carry a password — with the driver exception chained on as
116
+ ``__cause__``. A driver error raised mid-run, after the connection opened, is not a
117
+ configuration answer and is left as it is.
118
+ """
119
+ normalized = backend.strip().lower()
120
+ if normalized == "memory":
121
+ return _memory_saver()
122
+ if normalized == "sqlite":
123
+ return _sqlite_saver(url)
124
+ if normalized == "postgres":
125
+ return _postgres_saver(url)
126
+ raise ValueError(f"unknown checkpoint backend {backend!r}; expected sqlite, postgres, or memory")
127
+
128
+
129
+ @cache
130
+ def _memory_saver() -> BaseCheckpointSaver:
131
+ """Process-wide on purpose, unlike the durable two: ``MemorySaver`` is plain dicts with
132
+ no loop-bound primitive, and sharing it is what lets ``durable = True`` on the memory
133
+ backend resume across two ``asyncio.run`` calls at all — its threads live nowhere else."""
134
+ from langgraph.checkpoint.memory import MemorySaver
135
+
136
+ return MemorySaver()
137
+
138
+
139
+ def _sqlite_saver(url: str) -> BaseCheckpointSaver:
140
+ """``AsyncSqliteSaver`` for ``url``, one connection per event loop (see ``_per_loop``)."""
141
+ return _per_loop("sqlite", url, partial(_build_sqlite_saver, url))
142
+
143
+
144
+ def _build_sqlite_saver(url: str) -> BaseCheckpointSaver:
145
+ try:
146
+ import aiosqlite # ty: ignore[unresolved-import] — [durability] extra
147
+ from langgraph.checkpoint.sqlite import aio as sqlite_aio # ty: ignore[unresolved-import] — [durability] extra
148
+ except ImportError as exc:
149
+ raise ImportError(
150
+ f"checkpoint backend 'sqlite' needs langgraph-checkpoint-sqlite — {_DURABILITY_HINT}"
151
+ ) from exc
152
+
153
+ # AsyncSqliteSaver.__init__ needs a running loop — build it inside _run_sync, matching postgres.
154
+ path = url or ".agentdeck/checkpoints.sqlite3"
155
+
156
+ async def _connect_and_build() -> BaseCheckpointSaver:
157
+ try:
158
+ conn = aiosqlite.connect(path)
159
+ # aiosqlite's per-connection worker thread is non-daemon, and nothing ever closes a
160
+ # cached connection, so a normal exit would hang forever joining it.
161
+ conn._thread.daemon = True # noqa: SLF001 — aiosqlite exposes no public way to set this
162
+ await conn
163
+ saver = sqlite_aio.AsyncSqliteSaver(conn)
164
+ await saver.setup()
165
+ except sqlite3.Error as exc:
166
+ raise StoreError(f"cannot open the workflow checkpoint at {path!r} (AGENTDECK_CHECKPOINT): {exc}") from exc
167
+ return saver
168
+
169
+ saver: Any = _run_sync(_connect_and_build())
170
+ return saver
171
+
172
+
173
+ def _postgres_saver(url: str) -> BaseCheckpointSaver:
174
+ """``AsyncPostgresSaver`` for ``url``, one connection per event loop (see ``_per_loop``)."""
175
+ if not url:
176
+ raise ValueError("checkpoint backend 'postgres' needs a DSN")
177
+ return _per_loop("postgres", url, partial(_build_postgres_saver, url))
178
+
179
+
180
+ def _build_postgres_saver(url: str) -> BaseCheckpointSaver:
181
+ try:
182
+ from langgraph.checkpoint.postgres.aio import ( # ty: ignore[unresolved-import] — [durability] extra
183
+ AsyncPostgresSaver,
184
+ )
185
+ except ImportError as exc:
186
+ raise ImportError(
187
+ f"checkpoint backend 'postgres' needs langgraph-checkpoint-postgres — {_DURABILITY_HINT}",
188
+ ) from exc
189
+ import psycopg # ty: ignore[unresolved-import] — [durability] extra
190
+
191
+ # Async saver, same reason as sqlite: the engine always calls ``ainvoke``/``astream``.
192
+ # ``from_conn_string`` is an async contextmanager owning the connection; we enter it
193
+ # manually and let the caller cache the saver.
194
+ try:
195
+ saver: Any = _run_sync(AsyncPostgresSaver.from_conn_string(url).__aenter__())
196
+ _run_sync(saver.setup())
197
+ except psycopg.Error as exc:
198
+ # No DSN in the message, unlike the sqlite branch: a DSN can carry a password, and
199
+ # unlike a filesystem path, that is a secret. Matches how the networked event stores
200
+ # already word this (postgres/store.py, redis/store.py) — neither names its URL either.
201
+ raise StoreError(f"cannot open the workflow checkpoint (AGENTDECK_CHECKPOINT): {exc}") from exc
202
+ return saver
203
+
204
+
205
+ __all__ = ["resolve_checkpointer"]