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,359 @@
1
+ """The event log in Redis: the same contract as ``adapters.stores.sqlite``, shared by every
2
+ worker that can reach the instance.
3
+
4
+ Redis has no query planner to lean on, so the log carries its own indexes — a list per log
5
+ for append order, a list per run for a run's own slice, a sorted set of that run's ``seq``
6
+ numbers, the run's latest lifecycle event, and a set of the runs each log and each namespace
7
+ owns. Every write updates all of them inside one ``MULTI``/``EXEC``, so a reader never sees
8
+ an event in the log that is missing from its run's index. Status is still *derived* by
9
+ folding through ``core.status`` (ADR-D5: the log is the sole source of truth) — what the
10
+ indexes store is the last lifecycle **event**, never a status of their own.
11
+
12
+ Every key sits under one prefix (``agentdeck:events`` by default), which is how the
13
+ operational separation ADR-D5 asks for is expressed here: an instance that also holds the
14
+ openai-agents adapter's ``RedisSession`` conversations (``agents:session``) keeps the
15
+ platform record and the engine's private execution state in disjoint keyspaces, and either
16
+ can be dropped without touching the other.
17
+
18
+ **Operating notes.** ``append`` returning means Redis acknowledged the write, which is only
19
+ as durable as the instance is configured to be. The port promises an event a consumer has
20
+ seen is already in the store, so a deployment that uses this store as its record wants
21
+ ``appendonly yes``; with the default snapshot-only persistence a crash can lose the last
22
+ seconds of a log. It also wants ``maxmemory-policy noeviction``: this is a record, not a
23
+ cache, and an evicted key does not merely disappear — a run whose latest lifecycle event was
24
+ evicted stops being seen as holding its session, so a live turn can have its session taken
25
+ from under it. Both settings belong to the whole instance, which is another reason to give
26
+ the log its own rather than share a cache's.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from datetime import UTC, datetime
33
+ from typing import TYPE_CHECKING
34
+ from urllib.parse import quote, unquote
35
+
36
+ from redis.asyncio import Redis
37
+ from redis.exceptions import RedisError, WatchError
38
+
39
+ from agentdeck.core.events import Event
40
+ from agentdeck.core.ports import EventStorePort, RunSummary, SessionClaim
41
+ from agentdeck.core.status import LIFECYCLE_KINDS, TERMINAL_STATUSES, can_resume, status_of
42
+ from agentdeck.errors import StoreError
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
46
+ from datetime import timedelta
47
+
48
+ from redis.asyncio.client import Pipeline
49
+
50
+ from agentdeck.core.context import RunContext
51
+ from agentdeck.core.events import KnownPayload, RunResumed, RunStarted
52
+ from agentdeck.core.status import RunStatus
53
+
54
+
55
+ def _now() -> datetime:
56
+ return datetime.now(UTC)
57
+
58
+
59
+ _DEFAULT_PREFIX = "agentdeck:events"
60
+
61
+ # A claim re-reads and re-decides when a peer wrote to the same log under it. Bounded
62
+ # because an unbounded optimistic retry is a hang: past this many rounds the honest answer
63
+ # is that the store is too contended to settle, which is a store error and not a refusal.
64
+ #
65
+ # The loop is lock-step: one winner per round dirties every other watcher on the key, so N
66
+ # simultaneous contenders need exactly N rounds to drain. This must therefore stay well clear
67
+ # of any test's contender count — at 20 it sat exactly on `_CONCURRENT_APPENDS`, where one more
68
+ # contender turns a passing case into a permanently red one rather than a flaky one.
69
+ _CLAIM_ATTEMPTS = 64
70
+
71
+
72
+ def _segment(value: str) -> str:
73
+ """One key segment, escaped so ``:`` inside a namespace or session id cannot forge another.
74
+
75
+ Without this, namespace ``"a:b"`` + log ``"c"`` and namespace ``"a"`` + log ``"b:c"`` are the
76
+ same key, and two namespaces read each other's runs — the isolation every store owes.
77
+ """
78
+ return quote(value, safe="")
79
+
80
+
81
+ class RedisEventStore(EventStorePort):
82
+ """Append-only lists and their indexes under one Redis key prefix.
83
+
84
+ Every write goes through ``WATCH``/``MULTI``/``EXEC`` — the two claims and the plain
85
+ append, which is conditional on one ``seq`` per run: the keys the decision reads are
86
+ watched, the decision itself runs here in Python — so ``core.status`` stays the one place
87
+ a status is derived — and ``EXEC`` refuses to apply the write if any of those keys moved
88
+ in between. A peer that got there first therefore never loses to a stale read; the write
89
+ re-reads and answers from what the peer actually wrote. That is what makes this hold
90
+ between two servers and not merely between two tasks.
91
+
92
+ A ``redis`` exception never crosses the port: everything reaches the caller as
93
+ ``StoreError``.
94
+ """
95
+
96
+ def __init__(self, url: str, *, prefix: str = _DEFAULT_PREFIX, clock: Callable[[], datetime] = _now) -> None:
97
+ # decode_responses because every value here is UTF-8 JSON this store wrote itself.
98
+ self._client: Redis = Redis.from_url(url, decode_responses=True)
99
+ self._prefix = prefix
100
+ # An injected callable rather than Redis's own TIME, per ADR-D11 §4: reading the server's
101
+ # clock costs a round trip on the hot path, and unlike the SQL stores this one already
102
+ # holds a WATCH across the read — the clock is not what its atomicity rests on.
103
+ self._clock = clock
104
+
105
+ def _log_key(self, namespace: str, log_key: str) -> str:
106
+ return f"{self._prefix}:log:{_segment(namespace)}:{_segment(log_key)}"
107
+
108
+ def _run_key(self, namespace: str, log_key: str, run_id: str) -> str:
109
+ return f"{self._prefix}:run:{_segment(namespace)}:{_segment(log_key)}:{_segment(run_id)}"
110
+
111
+ def _seq_key(self, namespace: str, log_key: str, run_id: str) -> str:
112
+ return f"{self._prefix}:seq:{_segment(namespace)}:{_segment(log_key)}:{_segment(run_id)}"
113
+
114
+ def _life_key(self, namespace: str, log_key: str, run_id: str) -> str:
115
+ return f"{self._prefix}:life:{_segment(namespace)}:{_segment(log_key)}:{_segment(run_id)}"
116
+
117
+ def _log_runs_key(self, namespace: str, log_key: str) -> str:
118
+ return f"{self._prefix}:logruns:{_segment(namespace)}:{_segment(log_key)}"
119
+
120
+ def _namespace_runs_key(self, namespace: str) -> str:
121
+ return f"{self._prefix}:runs:{_segment(namespace)}"
122
+
123
+ def _queue_writes(self, pipe: Pipeline, namespace: str, log_key: str, events: Iterable[Event]) -> None:
124
+ """Buffer one batch's writes — the log, the run's slice and every index — onto a
125
+ pipeline already in ``MULTI``, so no concurrent reader sees part of them.
126
+
127
+ ``MULTI`` is atomic against *other clients*, not against a command of its own
128
+ failing: Redis runs the queued commands back to back and reports per-command errors
129
+ without rolling the rest back. Nothing here can fail on well-formed input — the keys
130
+ are this store's own and each command matches the type it created — so the case that
131
+ remains is a keyspace somebody else has written incompatible types into, which is
132
+ unrecoverable by any means this store has.
133
+ """
134
+ for event in events:
135
+ data = event.model_dump_json()
136
+ pipe.rpush(self._log_key(namespace, log_key), data)
137
+ pipe.rpush(self._run_key(namespace, log_key, event.run_id), data)
138
+ pipe.zadd(self._seq_key(namespace, log_key, event.run_id), {str(event.seq): event.seq})
139
+ if event.kind in LIFECYCLE_KINDS:
140
+ pipe.set(self._life_key(namespace, log_key, event.run_id), data)
141
+ pipe.sadd(self._log_runs_key(namespace, log_key), event.run_id)
142
+ pipe.sadd(self._namespace_runs_key(namespace), _member(log_key, event.run_id))
143
+
144
+ async def _stamp(
145
+ self, pipe: Pipeline, log_key: str, payloads: Sequence[KnownPayload], ctx: RunContext, origin: str
146
+ ) -> list[Event]:
147
+ """Read this run's next ``seq`` off its index and build the events, **watching** that
148
+ index so a peer spending the same number between here and ``EXEC`` aborts this write
149
+ instead of doubling it.
150
+
151
+ That watch is the whole atomicity mechanism, and it replaces the explicit spent-``seq``
152
+ check this store used to run: the number is no longer supplied by a caller who might
153
+ reuse one, and a peer racing for it loses the ``EXEC`` rather than being refused. The
154
+ index is a **ZSET** — ADR-D11 §6 originally prescribed ``INCR`` here, which would have
155
+ returned ``WRONGTYPE`` on every append; #153 corrected it.
156
+
157
+ Every payload in one call shares one ``ts``: a batch is one ``MULTI``, so it happened at
158
+ one instant.
159
+ """
160
+ seq_key = self._seq_key(ctx.namespace_key, log_key, ctx.run_id)
161
+ await pipe.watch(seq_key)
162
+ scored = await pipe.zrange(seq_key, 0, 0, desc=True, withscores=True)
163
+ seq = (int(scored[0][1]) if scored else -1) + 1
164
+ now = self._clock()
165
+ events = []
166
+ for offset, payload in enumerate(payloads):
167
+ events.append(
168
+ Event(
169
+ kind=payload.kind,
170
+ seq=seq + offset,
171
+ run_id=ctx.run_id,
172
+ session_id=ctx.session_id,
173
+ namespace=ctx.namespace,
174
+ origin=origin,
175
+ ts=now,
176
+ payload=payload,
177
+ )
178
+ )
179
+ return events
180
+
181
+ async def append(self, log_key: str, payloads: Sequence[KnownPayload], ctx: RunContext, origin: str) -> list[Event]:
182
+ """A plain append is a conditional one too: the run's ``seq`` index is watched, read and
183
+ extended over one ``WATCH``/``MULTI``/``EXEC``, exactly as the claims do."""
184
+ if not payloads:
185
+ return []
186
+
187
+ async def _attempt(pipe: Pipeline) -> list[Event]:
188
+ events = await self._stamp(pipe, log_key, payloads, ctx, origin)
189
+ pipe.multi()
190
+ self._queue_writes(pipe, ctx.namespace_key, log_key, events)
191
+ await pipe.execute()
192
+ return events
193
+
194
+ return await self._watched(_attempt, "append")
195
+
196
+ async def read(self, log_key: str, ctx: RunContext, offset: int = 0, limit: int | None = None) -> list[Event]:
197
+ if limit is not None and limit < 0:
198
+ raise ValueError(f"limit must be None or >= 0, got {limit}")
199
+ start = max(offset, 0)
200
+ end = -1 if limit is None else start + limit - 1
201
+ # A zero limit computes an end before the start, and LRANGE reads a negative index
202
+ # from the tail — which for offset 0 would be the whole log rather than no page.
203
+ if limit is not None and end < start:
204
+ return []
205
+ try:
206
+ rows = await self._client.lrange(self._log_key(ctx.namespace_key, log_key), start, end)
207
+ except RedisError as exc:
208
+ raise StoreError(f"event log read failed: {exc}") from exc
209
+ return [Event.model_validate(json.loads(row)) for row in rows]
210
+
211
+ async def read_run(self, log_key: str, run_id: str, ctx: RunContext, from_seq: int = 0) -> list[Event]:
212
+ try:
213
+ rows = await self._client.lrange(self._run_key(ctx.namespace_key, log_key, run_id), 0, -1)
214
+ except RedisError as exc:
215
+ raise StoreError(f"event log read_run failed: {exc}") from exc
216
+ events = [Event.model_validate(json.loads(row)) for row in rows]
217
+ return [event for event in events if event.seq >= from_seq]
218
+
219
+ async def claim_start(
220
+ self, log_key: str, opening: RunStarted, ctx: RunContext, origin: str, stale_after: timedelta
221
+ ) -> tuple[SessionClaim, Event | None]:
222
+ """The port's session claim over ``WATCH``/``MULTI``/``EXEC``: the log's set of runs
223
+ and every open run's own keys are watched, so a peer opening a run under this
224
+ decision aborts the write rather than doubling it.
225
+
226
+ A refusal is data, as the port requires — the losing caller re-reads and names the
227
+ run that actually holds the session. Only an unreachable store or a hopelessly
228
+ contended one raises.
229
+ """
230
+
231
+ async def _attempt(pipe: Pipeline) -> tuple[SessionClaim, Event | None]:
232
+ await pipe.watch(self._log_runs_key(ctx.namespace_key, log_key))
233
+ run_ids = _sorted_text(await pipe.smembers(self._log_runs_key(ctx.namespace_key, log_key)))
234
+ if run_ids:
235
+ await pipe.watch(
236
+ *(self._life_key(ctx.namespace_key, log_key, run_id) for run_id in run_ids),
237
+ *(self._run_key(ctx.namespace_key, log_key, run_id) for run_id in run_ids),
238
+ )
239
+ stale_before = self._clock() - stale_after
240
+ overridden: list[Event] = []
241
+ for run_id in run_ids:
242
+ life = await pipe.get(self._life_key(ctx.namespace_key, log_key, run_id))
243
+ # A key the same MULTI filled in coming back empty means the keyspace lost it
244
+ # — eviction, or an operator's DEL. Read as a run holding nothing rather than
245
+ # crashing the claim, and note what that costs: a *live* run whose lifecycle
246
+ # key went missing silently loses its session hold, and is not even reported
247
+ # in `overridden` for the winner to close. Hence `noeviction` up top; there is
248
+ # no answer a store can give here that is better than not evicting the record.
249
+ if life is None or status_of([Event.model_validate(json.loads(life))]) in TERMINAL_STATUSES:
250
+ continue
251
+ last = await pipe.lindex(self._run_key(ctx.namespace_key, log_key, run_id), -1)
252
+ if last is None:
253
+ continue
254
+ tail = Event.model_validate(json.loads(last))
255
+ if tail.ts > stale_before:
256
+ return SessionClaim(held_by=run_id), None
257
+ overridden.append(tail)
258
+ events = await self._stamp(pipe, log_key, [opening], ctx, origin)
259
+ pipe.multi()
260
+ self._queue_writes(pipe, ctx.namespace_key, log_key, events)
261
+ await pipe.execute()
262
+ return SessionClaim(overridden=tuple(overridden)), events[0]
263
+
264
+ return await self._watched(_attempt, "claim_start")
265
+
266
+ async def claim_resume(
267
+ self, log_key: str, run_id: str, resumed: RunResumed, ctx: RunContext, origin: str
268
+ ) -> Event | None:
269
+ """The port's conditional append over ``WATCH``/``MULTI``/``EXEC``: the run's status
270
+ and its ``seq`` index are watched, so the write that publishes the
271
+ ``WAITING_HUMAN`` -> ``RUNNING`` transition is the write that tested for it.
272
+
273
+ A loser gets its clean ``None`` from what the winner wrote, never from a guess. An
274
+ unreachable store raises instead, because it cannot know whether anybody resumed.
275
+ """
276
+ if ctx.run_id != run_id:
277
+ raise ValueError(f"a claim on run {run_id!r} cannot be made in the context of {ctx.run_id!r}")
278
+
279
+ async def _attempt(pipe: Pipeline) -> Event | None:
280
+ life_key = self._life_key(ctx.namespace_key, log_key, run_id)
281
+ await pipe.watch(life_key)
282
+ life = await pipe.get(life_key)
283
+ if not can_resume(status_of([Event.model_validate(json.loads(life))] if life is not None else [])):
284
+ return None
285
+ events = await self._stamp(pipe, log_key, [resumed], ctx, origin)
286
+ pipe.multi()
287
+ self._queue_writes(pipe, ctx.namespace_key, log_key, events)
288
+ await pipe.execute()
289
+ return events[0]
290
+
291
+ return await self._watched(_attempt, "claim_resume")
292
+
293
+ async def list_runs(self, ctx: RunContext, status: RunStatus | None = None) -> list[RunSummary]:
294
+ """Overrides the port's per-run fold: the namespace's runs come off one set and each
295
+ one's status off its stored last lifecycle event, so a listing deserializes one
296
+ event per run instead of every event of every log."""
297
+ try:
298
+ members = _sorted_text(await self._client.smembers(self._namespace_runs_key(ctx.namespace_key)))
299
+ runs = [_split(member) for member in members]
300
+ async with self._client.pipeline(transaction=False) as pipe:
301
+ for log_key, run_id in runs:
302
+ pipe.get(self._life_key(ctx.namespace_key, log_key, run_id))
303
+ lifecycles = await pipe.execute()
304
+ except RedisError as exc:
305
+ raise StoreError(f"event log list_runs failed: {exc}") from exc
306
+ summaries = [
307
+ RunSummary(log_key=log_key, run_id=run_id, status=status_of([Event.model_validate(json.loads(life))]))
308
+ for (log_key, run_id), life in zip(runs, lifecycles, strict=True)
309
+ if life is not None
310
+ ]
311
+ return [summary for summary in summaries if status is None or summary.status is status]
312
+
313
+ async def _watched[T](self, attempt: Callable[[Pipeline], Awaitable[T]], op: str) -> T:
314
+ """Run one optimistic write until ``EXEC`` is not aborted by a concurrent one.
315
+
316
+ Every write this store does goes through here — the two claims and the plain append,
317
+ which is conditional on one ``seq`` per run. A fresh pipeline per round, so a round
318
+ that lost its watch leaves nothing behind.
319
+ """
320
+ try:
321
+ for _ in range(_CLAIM_ATTEMPTS):
322
+ async with self._client.pipeline(transaction=True) as pipe:
323
+ try:
324
+ return await attempt(pipe)
325
+ except WatchError:
326
+ continue
327
+ except RedisError as exc:
328
+ raise StoreError(f"event log {op} failed: {exc}") from exc
329
+ raise StoreError(f"event log {op} gave up after {_CLAIM_ATTEMPTS} contended attempts")
330
+
331
+ async def aclose(self) -> None:
332
+ try:
333
+ await self._client.aclose()
334
+ except RedisError as exc:
335
+ raise StoreError(f"closing the event log failed: {exc}") from exc
336
+
337
+
338
+ def _sorted_text(members: Iterable[bytes | str]) -> list[str]:
339
+ """One set's members as sorted text.
340
+
341
+ ``redis`` types every reply as ``bytes | str`` because decoding is a client option
342
+ rather than a protocol fact, and this client asks for text. Decoding the other branch
343
+ anyway costs nothing and keeps the annotation honest. Sorted so that a log with two
344
+ live runs names the same holder to every caller instead of whichever one the set
345
+ happened to yield first.
346
+ """
347
+ return sorted(member.decode() if isinstance(member, bytes) else member for member in members)
348
+
349
+
350
+ def _member(log_key: str, run_id: str) -> str:
351
+ return f"{_segment(log_key)}:{_segment(run_id)}"
352
+
353
+
354
+ def _split(member: str) -> tuple[str, str]:
355
+ log_key, _, run_id = member.partition(":")
356
+ return unquote(log_key), unquote(run_id)
357
+
358
+
359
+ __all__ = ["RedisEventStore"]
@@ -0,0 +1,5 @@
1
+ """The event log in SQLite."""
2
+
3
+ from agentdeck.adapters.stores.sqlite.store import SqliteEventStore
4
+
5
+ __all__ = ["SqliteEventStore"]