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
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 @@
|
|
|
1
|
+
"""Run control — implementations of ``ControlPort``."""
|
|
@@ -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,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"]
|