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,338 @@
1
+ """The event log in SQLite: the same contract as ``adapters.stores.memory``, durable.
2
+
3
+ New code, not a port of ``runtime/sessions.py`` — that module is engine-private execution
4
+ state (ADR-D5), a different store with a different owner. This one is the platform record:
5
+ append-only, one row per event, ``seq`` scoped to one run within one log.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import sqlite3
13
+ from contextlib import suppress
14
+ from datetime import datetime
15
+ from functools import partial
16
+ from typing import TYPE_CHECKING
17
+
18
+ from agentdeck.core.events import Event
19
+ from agentdeck.core.ports import EventStorePort, RunSummary, SessionClaim
20
+ from agentdeck.core.status import LIFECYCLE_KINDS, TERMINAL_STATUSES, can_resume, status_of
21
+ from agentdeck.errors import StoreError
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable, Sequence
25
+ from datetime import timedelta
26
+ from pathlib import Path
27
+
28
+ from agentdeck.core.context import RunContext
29
+ from agentdeck.core.events import KnownPayload, RunResumed, RunStarted
30
+ from agentdeck.core.status import RunStatus
31
+
32
+ _SCHEMA = """
33
+ CREATE TABLE IF NOT EXISTS events (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ namespace TEXT NOT NULL,
36
+ log_key TEXT NOT NULL,
37
+ run_id TEXT NOT NULL,
38
+ seq INTEGER NOT NULL,
39
+ data TEXT NOT NULL
40
+ );
41
+ CREATE INDEX IF NOT EXISTS events_by_log ON events (namespace, log_key, id);
42
+ CREATE UNIQUE INDEX IF NOT EXISTS events_by_run ON events (namespace, log_key, run_id, seq);
43
+ """
44
+ # UNIQUE is the guard, not just the index: one seq per run is the promise consumers refetch a
45
+ # gap with, and a duplicate is the one corruption a gap check cannot see. A run whose process
46
+ # was presumed dead and then wrote again fails loudly here instead of putting two events at one
47
+ # seq. ponytail: only files this build creates get the constraint — a database from an earlier
48
+ # beta keeps its non-unique index, and v2 has no migration story yet.
49
+
50
+ _INSERT = "INSERT INTO events (namespace, log_key, run_id, seq, data) VALUES (?, ?, ?, ?, ?)"
51
+
52
+ _SORTED_LIFECYCLE_KINDS = tuple(sorted(LIFECYCLE_KINDS))
53
+ _KIND_SLOTS = ", ".join("?" * len(_SORTED_LIFECYCLE_KINDS))
54
+
55
+ # Pinned here rather than inherited from ``sqlite3.connect``'s own default: long enough that a
56
+ # peer's write transaction — milliseconds of one append — is waited out rather than raised
57
+ # over, short enough that a wedged holder surfaces as an error instead of hanging a request.
58
+ _BUSY_TIMEOUT_MS = 5_000
59
+
60
+
61
+ def _connect(db_path: str) -> sqlite3.Connection:
62
+ """Open the log with the concurrency posture two processes need, and its table."""
63
+ try:
64
+ conn = sqlite3.connect(db_path, check_same_thread=False)
65
+ # Before the journal mode, so the switch itself can wait a peer's transaction out.
66
+ conn.execute(f"PRAGMA busy_timeout = {_BUSY_TIMEOUT_MS}")
67
+ _enable_wal(conn)
68
+ conn.executescript(_SCHEMA)
69
+ conn.commit()
70
+ except sqlite3.Error as exc:
71
+ raise StoreError(f"cannot open the event log at {db_path!r}: {exc}") from exc
72
+ return conn
73
+
74
+
75
+ def _enable_wal(conn: sqlite3.Connection) -> None:
76
+ """Put the file in WAL, settling for the mode it already has if it cannot be switched now.
77
+
78
+ Converting *into* WAL needs an exclusive lock, and a peer holding the write lock denies
79
+ that outright — SQLite refuses immediately there, whatever the busy timeout says. Asking
80
+ again on a file that is already WAL is free even mid-write, so the only connection that
81
+ can lose this is the first one to a brand-new file racing another; it then runs in the
82
+ rollback-journal mode every connection used before WAL was asked for at all, which is
83
+ slower under contention and never wrong. An in-memory database reports ``memory`` and
84
+ stays there — there is no WAL for it to switch to, and nothing to work around.
85
+ """
86
+ if conn.execute("PRAGMA journal_mode").fetchone()[0] == "wal":
87
+ return
88
+ # ponytail: the degraded mode is invisible — log it if an operator ever has to find out
89
+ # why one process's store came up slower than its peers'.
90
+ with suppress(sqlite3.OperationalError):
91
+ conn.execute("PRAGMA journal_mode = WAL")
92
+
93
+
94
+ class SqliteEventStore(EventStorePort):
95
+ """Append-only rows in one SQLite file (or ``:memory:`` for tests).
96
+
97
+ One connection, serialized by a lock: ``sqlite3`` is stdlib but not coroutine-safe, and
98
+ a single writer per log is exactly the contract the Runtime already assumes — a lock is
99
+ simpler than a pool for that shape. Blocking calls run in a thread so the event loop is
100
+ never stalled by disk I/O, and a failed statement reaches the caller as ``StoreError``:
101
+ a ``sqlite3`` type never crosses the port.
102
+
103
+ The file is put in **WAL** mode and every connection sets an explicit busy timeout,
104
+ because the point of this store is that a second OS process reads and writes the same
105
+ file: WAL lets those readers run while a writer appends, and the timeout makes a peer's
106
+ in-flight write something to wait out rather than raise over. Two consequences for whoever
107
+ operates it: SQLite keeps ``<db>-wal`` and ``<db>-shm`` files beside the database — copy
108
+ or delete them with it, never just the one file — and WAL needs working shared memory
109
+ across processes, so it is unreliable on network filesystems like NFS or SMB. Keep the
110
+ events file on local disk; a networked deployment wants the Redis or Postgres store.
111
+ """
112
+
113
+ def __init__(self, db_path: str | Path = ":memory:") -> None:
114
+ self._conn = _connect(str(db_path))
115
+ self._lock = asyncio.Lock()
116
+
117
+ async def _run[T](self, work: Callable[[], T], op: str) -> T:
118
+ """Every statement this store runs goes through here — one caller at a time, off the
119
+ event loop, and no library exception escaping the port."""
120
+ async with self._lock:
121
+ try:
122
+ return await asyncio.to_thread(work)
123
+ except sqlite3.Error as exc:
124
+ raise StoreError(f"event log {op} failed: {exc}") from exc
125
+
126
+ async def append(self, log_key: str, payloads: Sequence[KnownPayload], ctx: RunContext, origin: str) -> list[Event]:
127
+ return await self._run(partial(self._append, log_key, list(payloads), ctx, origin), "append")
128
+
129
+ async def read(self, log_key: str, ctx: RunContext, offset: int = 0, limit: int | None = None) -> list[Event]:
130
+ if limit is not None and limit < 0:
131
+ raise ValueError(f"limit must be None or >= 0, got {limit}")
132
+ rows = await self._run(partial(self._select_log, ctx.namespace_key, log_key, max(offset, 0), limit), "read")
133
+ return [Event.model_validate(json.loads(row)) for row in rows]
134
+
135
+ async def read_run(self, log_key: str, run_id: str, ctx: RunContext, from_seq: int = 0) -> list[Event]:
136
+ rows = await self._run(partial(self._select_run, ctx.namespace_key, log_key, run_id, from_seq), "read_run")
137
+ return [Event.model_validate(json.loads(row)) for row in rows]
138
+
139
+ async def claim_start(
140
+ self, log_key: str, opening: RunStarted, ctx: RunContext, origin: str, stale_after: timedelta
141
+ ) -> tuple[SessionClaim, Event | None]:
142
+ """The port's session claim as one ``BEGIN IMMEDIATE`` transaction, for the same reason
143
+ ``claim_resume`` is one: the file's write lock, not this process, is what two servers
144
+ agree through, so only one of them can open a run on an idle session.
145
+
146
+ A refused claim is still a clean answer — the loser waited out the winner's transaction
147
+ and then read the run the winner opened. Only a lock held past the busy timeout raises,
148
+ because that is a store nobody can write to rather than a session somebody else took.
149
+ """
150
+ return await self._run(partial(self._claim_start, log_key, opening, ctx, origin, stale_after), "claim_start")
151
+
152
+ async def claim_resume(
153
+ self, log_key: str, run_id: str, resumed: RunResumed, ctx: RunContext, origin: str
154
+ ) -> Event | None:
155
+ """The port's conditional append as one ``BEGIN IMMEDIATE`` transaction, so the
156
+ winner is decided by SQLite's own write lock — the file, not this process, is what
157
+ two servers agree through.
158
+
159
+ A loser still gets its clean ``None``: it waits for the winner's transaction to
160
+ commit, then reads the ``RUNNING`` status the winner published. Only a lock held past
161
+ the busy timeout raises, and that is a store nobody can write to rather than a claim
162
+ somebody else won — ``StoreError``, never a fabricated ``None``.
163
+ """
164
+ if ctx.run_id != run_id:
165
+ raise ValueError(f"a claim on run {run_id!r} cannot be made in the context of {ctx.run_id!r}")
166
+ return await self._run(partial(self._claim, log_key, resumed, ctx, origin), "claim_resume")
167
+
168
+ async def list_runs(self, ctx: RunContext, status: RunStatus | None = None) -> list[RunSummary]:
169
+ """Overrides the port's per-run fold: one statement returns each run's *last*
170
+ lifecycle row, so a listing deserializes one event per run instead of all of them."""
171
+ rows = await self._run(partial(self._select_last_lifecycle, ctx.namespace_key), "list_runs")
172
+ summaries = [
173
+ RunSummary(log_key=log_key, run_id=run_id, status=status_of([Event.model_validate(json.loads(data))]))
174
+ for log_key, run_id, data in rows
175
+ ]
176
+ return [summary for summary in summaries if status is None or summary.status is status]
177
+
178
+ def _append(self, log_key: str, payloads: list[KnownPayload], ctx: RunContext, origin: str) -> list[Event]:
179
+ if not payloads: # as postgres and redis do — no reason to take the write lock for nothing
180
+ return []
181
+ # BEGIN IMMEDIATE, which a plain append never used to take. Reading MAX(seq) inside a
182
+ # *deferred* transaction and then inserting upgrades read→write mid-transaction, which
183
+ # SQLite answers with SQLITE_BUSY_SNAPSHOT — and it does not honour busy_timeout (#84),
184
+ # so a peer committing in between is an error rather than a wait. Taking the write lock
185
+ # first makes the read and the insert one step, which is the whole decision (ADR-D11).
186
+ self._conn.execute("BEGIN IMMEDIATE")
187
+ with self._conn:
188
+ return self._stamp_and_insert(log_key, payloads, ctx, origin)
189
+
190
+ def _stamp_and_insert(
191
+ self, log_key: str, payloads: list[KnownPayload], ctx: RunContext, origin: str
192
+ ) -> list[Event]:
193
+ """Assign, build, insert — callable only with the write lock already held.
194
+
195
+ Every payload in one call shares one ``ts``: the batch is a single indivisible write, so
196
+ it happened at one instant. Read from SQLite rather than this process, so N workers on one
197
+ file compare one clock (ADR-D11 §4).
198
+ """
199
+ now = self._backend_now()
200
+ seq = self._select_last_seq(ctx.namespace_key, log_key, ctx.run_id)
201
+ events = []
202
+ for payload in payloads:
203
+ seq += 1
204
+ events.append(
205
+ Event(
206
+ kind=payload.kind,
207
+ seq=seq,
208
+ run_id=ctx.run_id,
209
+ session_id=ctx.session_id,
210
+ namespace=ctx.namespace,
211
+ origin=origin,
212
+ ts=now,
213
+ payload=payload,
214
+ )
215
+ )
216
+ self._conn.executemany(
217
+ _INSERT,
218
+ [(ctx.namespace_key, log_key, event.run_id, event.seq, event.model_dump_json()) for event in events],
219
+ )
220
+ return events
221
+
222
+ def _backend_now(self) -> datetime:
223
+ """SQLite's clock, to millisecond precision.
224
+
225
+ ``CURRENT_TIMESTAMP`` is whole seconds, which would give every event in a busy second the
226
+ same ``ts`` — visible coarsening on the wire for no reason. ``%f`` is seconds with three
227
+ decimals, so the format below is the ISO string with an explicit UTC offset.
228
+ """
229
+ cursor = self._conn.execute("SELECT strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now')")
230
+ return datetime.fromisoformat(cursor.fetchone()[0])
231
+
232
+ def _claim_start(
233
+ self, log_key: str, opening: RunStarted, ctx: RunContext, origin: str, stale_after: timedelta
234
+ ) -> tuple[SessionClaim, Event | None]:
235
+ # Same lock-first reasoning as _claim: BEGIN IMMEDIATE takes the write lock before the
236
+ # reads, so a peer cannot open a run in the gap between finding this session idle and
237
+ # saying so. A deferred transaction would only lock at the insert, which is too late.
238
+ self._conn.execute("BEGIN IMMEDIATE")
239
+ with self._conn:
240
+ stale_before = self._backend_now() - stale_after
241
+ overridden: list[Event] = []
242
+ for _run_id, last in self._select_open_runs(ctx.namespace_key, log_key):
243
+ if last.ts > stale_before:
244
+ return SessionClaim(held_by=last.run_id), None
245
+ overridden.append(last)
246
+ event = self._stamp_and_insert(log_key, [opening], ctx, origin)[0]
247
+ return SessionClaim(overridden=tuple(overridden)), event
248
+
249
+ def _select_open_runs(self, namespace: str, log_key: str) -> list[tuple[str, Event]]:
250
+ """Every run in this log that has recorded a transition but not a terminal one, paired
251
+ with its own last event — whatever kind — because that event is the run's last sign of
252
+ life, and silence is all that separates an abandoned run from a working one.
253
+ """
254
+ cursor = self._conn.execute(
255
+ "SELECT run_id, data, MAX(id) FROM events "
256
+ f"WHERE namespace = ? AND log_key = ? AND json_extract(data, '$.kind') IN ({_KIND_SLOTS}) "
257
+ "GROUP BY run_id",
258
+ (namespace, log_key, *_SORTED_LIFECYCLE_KINDS),
259
+ )
260
+ open_runs = [
261
+ row[0]
262
+ for row in cursor.fetchall()
263
+ if status_of([Event.model_validate(json.loads(row[1]))]) not in TERMINAL_STATUSES
264
+ ]
265
+ return [(run_id, self._select_last_event(namespace, log_key, run_id)) for run_id in open_runs]
266
+
267
+ def _select_last_event(self, namespace: str, log_key: str, run_id: str) -> Event:
268
+ cursor = self._conn.execute(
269
+ "SELECT data, MAX(id) FROM events WHERE namespace = ? AND log_key = ? AND run_id = ?",
270
+ (namespace, log_key, run_id),
271
+ )
272
+ return Event.model_validate(json.loads(cursor.fetchone()[0]))
273
+
274
+ def _claim(self, log_key: str, resumed: RunResumed, ctx: RunContext, origin: str) -> Event | None:
275
+ # BEGIN IMMEDIATE takes the file's write lock before the reads, so a second process
276
+ # cannot see this run waiting in the gap between our check and our insert — a
277
+ # deferred transaction would only lock at the insert, which is exactly too late.
278
+ self._conn.execute("BEGIN IMMEDIATE")
279
+ # Commits the insert on the way out, rolls back if anything raised; the losing path
280
+ # wrote nothing, so its commit is only the write lock being handed back.
281
+ with self._conn:
282
+ last = self._select_last_lifecycle_of_run(ctx.namespace_key, log_key, ctx.run_id)
283
+ if not can_resume(status_of([Event.model_validate(json.loads(last))] if last is not None else [])):
284
+ return None
285
+ return self._stamp_and_insert(log_key, [resumed], ctx, origin)[0]
286
+
287
+ def _select_log(self, namespace: str, log_key: str, after: int, limit: int | None) -> list[str]:
288
+ # SQLite treats a negative LIMIT as "no limit" — the one case a plain int can't say.
289
+ cursor = self._conn.execute(
290
+ "SELECT data FROM events WHERE namespace = ? AND log_key = ? ORDER BY id ASC LIMIT ? OFFSET ?",
291
+ (namespace, log_key, -1 if limit is None else limit, after),
292
+ )
293
+ return [row[0] for row in cursor.fetchall()]
294
+
295
+ def _select_run(self, namespace: str, log_key: str, run_id: str, from_seq: int) -> list[str]:
296
+ cursor = self._conn.execute(
297
+ "SELECT data FROM events WHERE namespace = ? AND log_key = ? AND run_id = ? AND seq >= ? ORDER BY id ASC",
298
+ (namespace, log_key, run_id, from_seq),
299
+ )
300
+ return [row[0] for row in cursor.fetchall()]
301
+
302
+ def _select_last_seq(self, namespace: str, log_key: str, run_id: str) -> int:
303
+ cursor = self._conn.execute(
304
+ "SELECT MAX(seq) FROM events WHERE namespace = ? AND log_key = ? AND run_id = ?",
305
+ (namespace, log_key, run_id),
306
+ )
307
+ row = cursor.fetchone()
308
+ return row[0] if row is not None and row[0] is not None else -1
309
+
310
+ def _select_last_lifecycle_of_run(self, namespace: str, log_key: str, run_id: str) -> str | None:
311
+ cursor = self._conn.execute(
312
+ "SELECT data FROM events "
313
+ f"WHERE namespace = ? AND log_key = ? AND run_id = ? AND json_extract(data, '$.kind') IN ({_KIND_SLOTS}) "
314
+ "ORDER BY id DESC LIMIT 1",
315
+ (namespace, log_key, run_id, *_SORTED_LIFECYCLE_KINDS),
316
+ )
317
+ row = cursor.fetchone()
318
+ return row[0] if row is not None else None
319
+
320
+ def _select_last_lifecycle(self, namespace: str) -> list[tuple[str, str, str]]:
321
+ # SQLite guarantees the bare columns of a MAX() group come from the row that held the
322
+ # maximum, so this is the newest lifecycle event of each run in a single group-by.
323
+ cursor = self._conn.execute(
324
+ "SELECT log_key, run_id, data, MAX(id) FROM events "
325
+ f"WHERE namespace = ? AND json_extract(data, '$.kind') IN ({_KIND_SLOTS}) "
326
+ "GROUP BY log_key, run_id",
327
+ (namespace, *_SORTED_LIFECYCLE_KINDS),
328
+ )
329
+ return [(row[0], row[1], row[2]) for row in cursor.fetchall()]
330
+
331
+ def close(self) -> None:
332
+ try:
333
+ self._conn.close()
334
+ except sqlite3.Error as exc:
335
+ raise StoreError(f"closing the event log failed: {exc}") from exc
336
+
337
+
338
+ __all__ = ["SqliteEventStore"]
@@ -0,0 +1 @@
1
+ """Telemetry — read-only taps that turn the event stream into traces, one per backend."""
@@ -0,0 +1,18 @@
1
+ """Langfuse telemetry: the canonical event stream read once and rendered as traces."""
2
+
3
+ from agentdeck.adapters.telemetry.langfuse.client import LangfuseTracer, build_client, langfuse_sink
4
+ from agentdeck.adapters.telemetry.langfuse.sink import MAX_OPEN_CALLS, MAX_OPEN_RUNS, LangfuseSink
5
+ from agentdeck.adapters.telemetry.langfuse.trace import Level, Observation, ObservationKind, Tracer
6
+
7
+ __all__ = [
8
+ "MAX_OPEN_CALLS",
9
+ "MAX_OPEN_RUNS",
10
+ "LangfuseSink",
11
+ "LangfuseTracer",
12
+ "Level",
13
+ "Observation",
14
+ "ObservationKind",
15
+ "Tracer",
16
+ "build_client",
17
+ "langfuse_sink",
18
+ ]
@@ -0,0 +1,180 @@
1
+ """The Langfuse SDK boundary: the only module in the package that names ``langfuse``.
2
+
3
+ Two jobs. :func:`langfuse_sink` builds the client and the sink over it — the one place in the
4
+ package a client is constructed, reached only when a ``Deck`` that asked for tracing is
5
+ opening, so nothing is imported and no client exists before then. :class:`LangfuseTracer` is
6
+ the SDK-backed ``Tracer``: it opens observations and hands back handles, and every call it
7
+ makes is in-memory. Delivery belongs to the SDK's batching span processor, which ships from a
8
+ background thread, so ``emit`` never waits on the network; the sink's ``close`` is what makes
9
+ that buffer leave the process at shutdown, instead of trusting an ``atexit`` a killed process
10
+ never runs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from agentdeck.adapters.telemetry.langfuse.sink import LangfuseSink
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping
23
+
24
+ from agentdeck.adapters.telemetry.langfuse.trace import Level, Observation, ObservationKind
25
+ from agentdeck.core.events import Usage
26
+ from agentdeck.runtime.settings import LangfuseSettings
27
+
28
+ # Graceful degradation when Langfuse is unreachable. The OTLP HTTP exporter retries a failed
29
+ # batch with exponential backoff *up to its timeout*, logging a WARNING each try; against a
30
+ # dead backend that is pure noise that also blocks flush-on-exit. The per-export timeout is
31
+ # bounded (seconds — the exporter reads this env, default 10) so retries give up fast, and the
32
+ # exporter's log level is raised so transient-retry WARNINGs stay silent. A genuine,
33
+ # non-retryable config error (bad keys, 4xx) is logged at ERROR and still surfaces.
34
+ _EXPORT_TIMEOUT_ENV = "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT"
35
+ _EXPORT_TIMEOUT_SECONDS = "5"
36
+ _OTLP_EXPORTER_LOGGER = "opentelemetry.exporter.otlp.proto.http.trace_exporter"
37
+
38
+
39
+ def langfuse_sink(settings: LangfuseSettings) -> LangfuseSink:
40
+ """The sink to register with the Runtime, over a client built for ``settings``.
41
+
42
+ Called once, by a ``Deck`` that was asked for tracing, as it opens — never from settings by
43
+ whatever happens to be assembling a Runtime. Takes the settings rather than reading them,
44
+ so "is tracing wanted?" is answered by the caller and this module only answers "how".
45
+ """
46
+ return LangfuseSink(LangfuseTracer(build_client(settings)))
47
+
48
+
49
+ def build_client(settings: LangfuseSettings) -> Any:
50
+ """Construct the SDK client. Imported here, never at module scope, so the optional
51
+ ``[observability]`` extra stays optional.
52
+
53
+ One client, built once, is a hard requirement rather than a preference. The SDK caches a
54
+ ``LangfuseResourceManager`` per public key and returns the cached one from every later
55
+ ``Langfuse(...)`` call, discarding that call's arguments — so a second construction cannot
56
+ change the environment, the sample rate or the span filter the first one set, and it is not
57
+ a second client either. Shutting one down does not evict it from that cache, which is why
58
+ nothing here shuts one down: see ``Deck.aclose``.
59
+ """
60
+ from langfuse import Langfuse # ty: ignore[unresolved-import] — [observability] extra
61
+
62
+ # Name the OTel resource before the SDK builds its TracerProvider — ``Resource.create``
63
+ # reads ``OTEL_SERVICE_NAME``, so this replaces the default ``unknown_service``. Respect an
64
+ # operator-set value.
65
+ os.environ.setdefault("OTEL_SERVICE_NAME", settings.service_name)
66
+ # Bound + quiet the exporter before it is built, so a down Langfuse can't slow or spam a run.
67
+ os.environ.setdefault(_EXPORT_TIMEOUT_ENV, _EXPORT_TIMEOUT_SECONDS)
68
+ logging.getLogger(_OTLP_EXPORTER_LOGGER).setLevel(logging.ERROR)
69
+ return Langfuse(
70
+ public_key=settings.public_key,
71
+ secret_key=settings.secret_key,
72
+ base_url=settings.base_url,
73
+ environment=settings.environment,
74
+ debug=settings.debug,
75
+ sample_rate=settings.sample_rate,
76
+ )
77
+
78
+
79
+ class LangfuseTracer:
80
+ """``Tracer`` over the Langfuse SDK, with one trace id derived from each run's key.
81
+
82
+ Deriving the trace id instead of letting the SDK mint one does two things. It pins the
83
+ root to the run rather than to whatever span happens to be current — the sink runs on a
84
+ consumer task that inherited its context from a run, and a trace must not end up nested
85
+ under the tracing v1 already does. And it makes the key the identity: the same run
86
+ resumed in another process reopens the same trace instead of starting a second one.
87
+ """
88
+
89
+ def __init__(self, client: Any) -> None:
90
+ self._client = client
91
+
92
+ def root(
93
+ self,
94
+ name: str,
95
+ *,
96
+ kind: ObservationKind,
97
+ trace_key: str,
98
+ session_id: str | None,
99
+ input: Any = None,
100
+ metadata: Mapping[str, Any] | None = None,
101
+ ) -> Observation:
102
+ from langfuse import propagate_attributes # ty: ignore[unresolved-import] — [observability] extra
103
+
104
+ # Session, user and trace name are trace-level in Langfuse: they reach the backend as
105
+ # attributes stamped on spans opened inside this context, so the root is opened inside it.
106
+ with propagate_attributes(
107
+ session_id=session_id,
108
+ trace_name=name,
109
+ metadata=dict(metadata) if metadata else None,
110
+ ):
111
+ return _LangfuseObservation(
112
+ self._client.start_observation(
113
+ trace_context={"trace_id": self._client.create_trace_id(seed=trace_key)},
114
+ name=name,
115
+ as_type=kind,
116
+ input=input,
117
+ )
118
+ )
119
+
120
+ def flush(self) -> None:
121
+ """Hand the SDK's buffer to Langfuse now, rather than hoping the process exits cleanly
122
+ enough for its ``atexit`` to do it."""
123
+ self._client.flush()
124
+
125
+
126
+ class _LangfuseObservation:
127
+ """``Observation`` over one Langfuse span handle."""
128
+
129
+ __slots__ = ("_span",)
130
+
131
+ def __init__(self, span: Any) -> None:
132
+ self._span = span
133
+
134
+ def child(
135
+ self,
136
+ name: str,
137
+ *,
138
+ kind: ObservationKind,
139
+ input: Any = None,
140
+ metadata: Mapping[str, Any] | None = None,
141
+ ) -> Observation:
142
+ return _LangfuseObservation(
143
+ self._span.start_observation(
144
+ name=name, as_type=kind, input=input, metadata=dict(metadata) if metadata else None
145
+ )
146
+ )
147
+
148
+ def finish(
149
+ self,
150
+ *,
151
+ output: Any = None,
152
+ metadata: Mapping[str, Any] | None = None,
153
+ level: Level | None = None,
154
+ status: str | None = None,
155
+ usage: Usage | None = None,
156
+ ) -> None:
157
+ self._span.update(
158
+ output=output,
159
+ metadata=dict(metadata) if metadata else None,
160
+ level=level,
161
+ status_message=status,
162
+ usage_details=_usage_details(usage),
163
+ cost_details=_cost_details(usage),
164
+ )
165
+ self._span.end()
166
+
167
+
168
+ def _usage_details(usage: Usage | None) -> dict[str, int] | None:
169
+ if usage is None:
170
+ return None
171
+ return {"input": usage.input_tokens, "output": usage.output_tokens}
172
+
173
+
174
+ def _cost_details(usage: Usage | None) -> dict[str, float] | None:
175
+ if usage is None or usage.usd is None:
176
+ return None
177
+ return {"total": usage.usd}
178
+
179
+
180
+ __all__ = ["LangfuseTracer", "build_client", "langfuse_sink"]