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,100 @@
1
+ """The openai-agents adapter's private execution state (ADR-D5: the engine's working
2
+ memory is its own, never read by consumers).
3
+
4
+ ``SessionFactory`` is relocated here unchanged from ``runtime/sessions.py`` — kept, not
5
+ rewritten: it mints per-id :class:`agents.extensions.memory.RedisSession`
6
+ objects sharing one Redis client. ``ExecutionStore`` is the adapter's own seam on top of
7
+ it: Redis-backed when a factory is configured, one in-process
8
+ :class:`agents.SQLiteSession` per key otherwise — the same fallback ``agentdeck.deck.Deck``
9
+ uses for its chat methods, so both callers of the SDK agree on what "no Redis configured"
10
+ means. Nothing outside this adapter directory may import either class.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from agents import SQLiteSession
18
+ from agents.extensions.memory import RedisSession
19
+ from redis.asyncio import Redis
20
+
21
+ if TYPE_CHECKING:
22
+ from agents.memory.session import Session
23
+
24
+ from agentdeck.core.context import RunContext
25
+ from agentdeck.runtime.settings import SessionSettings
26
+
27
+
28
+ class SessionFactory:
29
+ """Builds per-id :class:`RedisSession` objects sharing one Redis client."""
30
+
31
+ def __init__(
32
+ self,
33
+ redis_client: Redis,
34
+ *,
35
+ key_prefix: str = "agents:session",
36
+ ttl: int | None = None,
37
+ ) -> None:
38
+ self._redis = redis_client
39
+ self._key_prefix = key_prefix
40
+ self._ttl = ttl
41
+
42
+ @classmethod
43
+ def from_settings(cls, settings: SessionSettings) -> SessionFactory | None:
44
+ """Build from :class:`SessionSettings` or return ``None`` when disabled."""
45
+ if not settings.url:
46
+ return None
47
+ return cls(
48
+ Redis.from_url(settings.url),
49
+ key_prefix=settings.redis_key_prefix,
50
+ ttl=settings.redis_ttl,
51
+ )
52
+
53
+ def session_for(self, session_id: str) -> Session:
54
+ return RedisSession(
55
+ session_id,
56
+ redis_client=self._redis,
57
+ key_prefix=self._key_prefix,
58
+ ttl=self._ttl,
59
+ )
60
+
61
+ async def aclose(self) -> None:
62
+ await self._redis.aclose()
63
+
64
+
65
+ class ExecutionStore:
66
+ """The engine's execution memory, keyed by ``(namespace, RunContext.log_key)`` — session,
67
+ or the run itself when there is no session.
68
+
69
+ Not the event log: this is engine-native state (ADR-D5) that only ``OpenAIAgentsEngine``
70
+ reads. ``session_factory`` set means Redis-backed and shared across processes; unset
71
+ falls back to one in-process ``SQLiteSession`` per key, so tests and the M0 skeleton
72
+ need no network. The namespace prefix matters even though ``log_key`` is usually a
73
+ server-generated session id: two namespaces are free to pick the same one, and without
74
+ it their conversations would share one SDK session — exactly the isolation the event
75
+ stores already enforce (``adapters/stores/memory``'s namespace-scoped buckets).
76
+ """
77
+
78
+ def __init__(self, session_factory: SessionFactory | None = None) -> None:
79
+ self._session_factory = session_factory
80
+ # Precisely SQLiteSession, not the broader Session protocol: aclose() below
81
+ # needs its close(), which isn't part of SessionABC.
82
+ self._local: dict[str, SQLiteSession] = {}
83
+
84
+ def session_for(self, ctx: RunContext) -> Session:
85
+ key = f"{ctx.namespace_key}:{ctx.log_key}"
86
+ if self._session_factory is not None:
87
+ return self._session_factory.session_for(key)
88
+ return self._local.setdefault(key, SQLiteSession(key))
89
+
90
+ async def aclose(self) -> None:
91
+ # ponytail: SQLiteSession.close() is sync and cheap for the :memory:/local-file
92
+ # fallback this is — a pool with async teardown is follow-up work if a real
93
+ # deployment ever wants the local path instead of Redis.
94
+ for session in self._local.values():
95
+ session.close()
96
+ if self._session_factory is not None:
97
+ await self._session_factory.aclose()
98
+
99
+
100
+ __all__ = ["ExecutionStore", "SessionFactory"]
@@ -0,0 +1,124 @@
1
+ """Stream-event → canonical-payload translation.
2
+
3
+ Ported from the delta-only extraction in ``agents/runners/headless.py`` (v1 stays
4
+ untouched) and widened to text deltas, completed messages, tool calls and their results.
5
+ Reasoning items are deliberately not translated (ADR-D5: they live only in the SDK
6
+ session, and mirroring them would chain the event schema to the SDK's item format).
7
+ Handoffs are not a core kind (D10: engines translate or namespace, never mint), so a
8
+ completed handoff becomes one namespaced ``custom`` event.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ from typing import Any
17
+
18
+ from agentdeck.core.events import (
19
+ RESULT_PREVIEW_MAX,
20
+ Custom,
21
+ KnownPayload,
22
+ MessageCompleted,
23
+ TextDelta,
24
+ ToolCallCompleted,
25
+ ToolCallStarted,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def translate(event: Any, tool_names: dict[str, str]) -> KnownPayload | None:
32
+ """One stream event → one payload, or ``None`` for anything M0 doesn't surface.
33
+
34
+ ``tool_names`` is the run's own ``call_id`` → tool-name map: ``tool.call.completed``
35
+ doesn't carry the name, so it is remembered from the paired ``tool.call.started``.
36
+ """
37
+ if event.type == "raw_response_event":
38
+ return _translate_raw(event.data)
39
+ if event.type != "run_item_stream_event":
40
+ return None # agent_updated_stream_event: bookkeeping only, no payload
41
+ return _translate_item(event.item, tool_names)
42
+
43
+
44
+ def _translate_raw(data: Any) -> KnownPayload | None:
45
+ if getattr(data, "type", None) != "response.output_text.delta":
46
+ return None # every other raw SDK event (created/completed/reasoning/...) is noise here
47
+ return TextDelta(message_id=data.item_id, text=data.delta)
48
+
49
+
50
+ def _translate_item(item: Any, tool_names: dict[str, str]) -> KnownPayload | None:
51
+ kind = item.type
52
+ if kind == "tool_call_item":
53
+ return _tool_call_started(item, tool_names)
54
+ if kind == "tool_call_output_item":
55
+ return _tool_call_completed(item, tool_names)
56
+ if kind == "message_output_item":
57
+ return _message_completed(item)
58
+ if kind == "handoff_output_item":
59
+ return _handoff(item)
60
+ # handoff_call_item (paired with handoff_output_item, nothing new to say), reasoning
61
+ # items, MCP approval items, computer-use items: not surfaced at M0.
62
+ return None
63
+
64
+
65
+ def _tool_call_started(item: Any, tool_names: dict[str, str]) -> KnownPayload | None:
66
+ raw = item.raw_item
67
+ call_id, name = getattr(raw, "call_id", None), getattr(raw, "name", None)
68
+ if call_id is None or name is None:
69
+ return None # non-function tool call (e.g. computer use) — out of scope for M0
70
+ tool_names[call_id] = name
71
+ args = _parse_args(getattr(raw, "arguments", None))
72
+ if "_raw" in args:
73
+ # Content-free by design: call_id and tool name only — the raw arguments string
74
+ # could carry user content and must never reach a log line.
75
+ logger.warning("tool call %s (%s) had non-dict JSON args, degraded to _raw", call_id, name)
76
+ return ToolCallStarted(call_id=call_id, tool=name, args=args)
77
+
78
+
79
+ def _parse_args(arguments: str | None) -> dict[str, Any]:
80
+ """Malformed model-emitted JSON degrades to a raw string field rather than killing the
81
+ run over a cosmetic payload attribute — the tool call itself still happened and is
82
+ still logged."""
83
+ if not arguments:
84
+ return {}
85
+ try:
86
+ parsed = json.loads(arguments)
87
+ except json.JSONDecodeError:
88
+ return {"_raw": arguments}
89
+ return parsed if isinstance(parsed, dict) else {"_raw": arguments}
90
+
91
+
92
+ def _tool_call_completed(item: Any, tool_names: dict[str, str]) -> KnownPayload:
93
+ call_id = item.call_id or ""
94
+ result = str(item.output)
95
+ encoded = result.encode()
96
+ # TODO(#52): a non-function tool call (computer-use, MCP approval) never populates
97
+ # tool_names via _tool_call_started (that function returns None for it), so its
98
+ # result lands here as an orphan tool.call.completed with tool="unknown" and no
99
+ # paired .started — out of scope for M0's function-tool-only UC1.
100
+ return ToolCallCompleted(
101
+ call_id=call_id,
102
+ tool=tool_names.get(call_id, "unknown"),
103
+ result_preview=result[:RESULT_PREVIEW_MAX],
104
+ result_size=len(encoded),
105
+ result_sha256=hashlib.sha256(encoded).hexdigest(),
106
+ )
107
+
108
+
109
+ def _message_completed(item: Any) -> KnownPayload:
110
+ raw = item.raw_item
111
+ text = "".join(getattr(part, "text", "") for part in getattr(raw, "content", ()))
112
+ return MessageCompleted(message_id=raw.id, text=text)
113
+
114
+
115
+ def _handoff(item: Any) -> KnownPayload:
116
+ # ADR-D5's invariant ("everything that enters or leaves execution state is recorded")
117
+ # without minting a kind (D10): one namespaced custom event, not a new payload class.
118
+ return Custom(
119
+ name="openai_agents.handoff",
120
+ data={"from": item.source_agent.name, "to": item.target_agent.name},
121
+ )
122
+
123
+
124
+ __all__ = ["translate"]
@@ -0,0 +1,5 @@
1
+ """The scripted stub engine — the reference implementation of the engine contract."""
2
+
3
+ from agentdeck.adapters.engines.stub.engine import Step, StubEngine, stub_spec
4
+
5
+ __all__ = ["Step", "StubEngine", "stub_spec"]
@@ -0,0 +1,100 @@
1
+ """The engine that plays a script — the reference implementation of ``EnginePort``.
2
+
3
+ An invocable's ``native`` is the script: payloads to yield in order, with any exception in
4
+ the sequence raised where it sits. That covers every way a run can end — completes, fails
5
+ mid-stream, interrupts, or stops without a terminal event — with no model, no network and
6
+ no timing, which is why it stays the contract suite's fastest engine rather than a
7
+ placeholder for a real one. A ``RunInterrupted`` step splits the script in two: ``start``
8
+ plays up to and including it, then stops (mirroring a real engine suspending); ``resume``
9
+ plays whatever comes after it — so one script expresses both halves of a suspend/resume
10
+ case without a second field on ``InvocableSpec``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Sequence
16
+ from typing import TYPE_CHECKING, Any, ClassVar
17
+
18
+ from agentdeck.core.control import ControlSignalled
19
+ from agentdeck.core.events import RunInterrupted
20
+ from agentdeck.core.invocable import InvocableKind, InvocableSpec
21
+ from agentdeck.core.ports import EnginePort
22
+ from agentdeck.errors import ConfigError
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import AsyncGenerator
26
+
27
+ from agentdeck.core.content import Input
28
+ from agentdeck.core.context import RunContext
29
+ from agentdeck.core.events import Event, KnownPayload
30
+
31
+ type Step = KnownPayload | Exception
32
+ """One scripted step: a payload to yield, or an exception to raise at that point."""
33
+
34
+
35
+ class StubEngine(EnginePort):
36
+ """Plays ``spec.native`` back as a run. Scripts are reusable: payloads are immutable.
37
+
38
+ Honors the gate between steps, so run control is part of what this engine models rather
39
+ than something only a real one can be tested against.
40
+ """
41
+
42
+ engine: ClassVar[str] = "stub"
43
+
44
+ async def start(
45
+ self,
46
+ spec: InvocableSpec,
47
+ input: Input,
48
+ history: Sequence[Event],
49
+ ctx: RunContext,
50
+ ) -> AsyncGenerator[KnownPayload, None]:
51
+ for step in _script_of(spec):
52
+ if isinstance(step, Exception):
53
+ raise step
54
+ yield step
55
+ if isinstance(step, RunInterrupted):
56
+ return # suspend here; resume() plays whatever the script has after this
57
+ try:
58
+ await ctx.gate.checkpoint()
59
+ except ControlSignalled as signalled:
60
+ # Between two steps is this engine's stream_item boundary — the same safe
61
+ # point a real engine has between two translated items, which is what lets
62
+ # the contract suite hold both to one control contract.
63
+ for payload in signalled.payloads:
64
+ yield payload
65
+ return
66
+
67
+ async def resume(
68
+ self,
69
+ spec: InvocableSpec,
70
+ thread_id: str,
71
+ value: Any,
72
+ ctx: RunContext,
73
+ ) -> AsyncGenerator[KnownPayload, None]:
74
+ after_interrupt = False
75
+ for step in _script_of(spec):
76
+ if not after_interrupt:
77
+ after_interrupt = isinstance(step, RunInterrupted)
78
+ continue
79
+ if isinstance(step, Exception):
80
+ raise step
81
+ yield step
82
+
83
+
84
+ def stub_spec(name: str, *steps: Step, kind: InvocableKind = InvocableKind.AGENT) -> InvocableSpec:
85
+ """A scripted invocable. Leave ``run.started`` out — the Runtime opens every run itself."""
86
+ return InvocableSpec(name=name, kind=kind, engine=StubEngine.engine, native=steps)
87
+
88
+
89
+ def _script_of(spec: InvocableSpec) -> tuple[Step, ...]:
90
+ """A misconfigured invocable is the caller's mistake, not a run that failed — so this is a
91
+ ``ConfigError`` at the adapter's edge rather than a stdlib type leaking into ``run.failed``."""
92
+ script = spec.native
93
+ if isinstance(script, str) or not isinstance(script, Sequence):
94
+ raise ConfigError(
95
+ f"{spec.name!r} has no stub script: expected a sequence of payloads in native, got {type(script).__name__}"
96
+ )
97
+ return tuple(script)
98
+
99
+
100
+ __all__ = ["Step", "StubEngine", "stub_spec"]
@@ -0,0 +1 @@
1
+ """Event-log stores — implementations of ``EventStorePort``."""
@@ -0,0 +1,5 @@
1
+ """The event log in a dict."""
2
+
3
+ from agentdeck.adapters.stores.memory.store import MemoryEventStore
4
+
5
+ __all__ = ["MemoryEventStore"]
@@ -0,0 +1,148 @@
1
+ """The event log in a dict: the default for dev, tests and the contract suite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING
8
+
9
+ from agentdeck.core.events import Event
10
+ from agentdeck.core.ports import EventStorePort, RunSummary, SessionClaim
11
+ from agentdeck.core.status import LIFECYCLE_KINDS, TERMINAL_STATUSES, RunStatus, can_resume, status_of
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable, Sequence
15
+ from datetime import timedelta
16
+
17
+ from agentdeck.core.context import RunContext
18
+ from agentdeck.core.events import KnownPayload, RunResumed, RunStarted
19
+
20
+
21
+ def _now() -> datetime:
22
+ return datetime.now(UTC)
23
+
24
+
25
+ class MemoryEventStore(EventStorePort):
26
+ """Append-only lists, one per (namespace, log key). Process exit is data loss, by design.
27
+
28
+ Keyed by namespace as well as log key so two namespaces that pick the same session id cannot
29
+ read each other's runs — isolation is not something a store gets to skip.
30
+ """
31
+
32
+ def __init__(self, clock: Callable[[], datetime] = _now) -> None:
33
+ self._logs: dict[tuple[str | None, str], list[Event]] = {}
34
+ self._clock = clock
35
+
36
+ async def append(self, log_key: str, payloads: Sequence[KnownPayload], ctx: RunContext, origin: str) -> list[Event]:
37
+ events = self._stamp(log_key, payloads, ctx, origin)
38
+ # Fidelity, not correctness (issue #87): every real store suspends here (SQLite's own
39
+ # `to_thread`), so a caller whose liveness secretly depends on that turn is caught by
40
+ # this store too, instead of only by measurement in production. Placed after the
41
+ # mutation in `_stamp`, so it opens no window in either claim's atomicity.
42
+ await asyncio.sleep(0)
43
+ return events
44
+
45
+ def _stamp(self, log_key: str, payloads: Sequence[KnownPayload], ctx: RunContext, origin: str) -> list[Event]:
46
+ """Assign, build and write, with no suspension point anywhere in between.
47
+
48
+ That is this store's whole atomicity mechanism, and it is why every caller here — both
49
+ claims included — goes through this rather than through ``append``: an ``await`` between
50
+ reading the run's last ``seq`` and extending the log is all it would take for two tasks to
51
+ be handed the same number.
52
+ """
53
+ log = self._logs.setdefault((ctx.namespace, log_key), [])
54
+ seq = max((stored.seq for stored in log if stored.run_id == ctx.run_id), default=-1)
55
+ events = []
56
+ for payload in payloads:
57
+ seq += 1
58
+ events.append(
59
+ Event(
60
+ kind=payload.kind,
61
+ seq=seq,
62
+ run_id=ctx.run_id,
63
+ session_id=ctx.session_id,
64
+ namespace=ctx.namespace,
65
+ origin=origin,
66
+ ts=self._clock(),
67
+ payload=payload,
68
+ )
69
+ )
70
+ log.extend(events)
71
+ return events
72
+
73
+ async def read(self, log_key: str, ctx: RunContext, offset: int = 0, limit: int | None = None) -> list[Event]:
74
+ if limit is not None and limit < 0:
75
+ raise ValueError(f"limit must be None or >= 0, got {limit}")
76
+ log = self._logs.get((ctx.namespace, log_key), ())
77
+ page = log[max(offset, 0) :]
78
+ return list(page if limit is None else page[:limit])
79
+
80
+ async def read_run(self, log_key: str, run_id: str, ctx: RunContext, from_seq: int = 0) -> list[Event]:
81
+ log = self._logs.get((ctx.namespace, log_key), ())
82
+ return [event for event in log if event.run_id == run_id and event.seq >= from_seq]
83
+
84
+ async def claim_start(
85
+ self, log_key: str, opening: RunStarted, ctx: RunContext, origin: str, stale_after: timedelta
86
+ ) -> tuple[SessionClaim, Event | None]:
87
+ """Atomic for free, like ``claim_resume``: the scan and ``_stamp`` are plain dict work
88
+ with no suspension point between them, so no other task can open a run in the gap.
89
+ """
90
+ stale_before = self._clock() - stale_after
91
+ overridden: list[Event] = []
92
+ for events in _by_run(self._logs.get((ctx.namespace, log_key), ())).values():
93
+ status = status_of(events)
94
+ if status is RunStatus.PENDING or status in TERMINAL_STATUSES:
95
+ continue
96
+ if events[-1].ts > stale_before:
97
+ return SessionClaim(held_by=events[-1].run_id), None
98
+ overridden.append(events[-1])
99
+ event = self._stamp(log_key, [opening], ctx, origin)[0]
100
+ await asyncio.sleep(0)
101
+ return SessionClaim(overridden=tuple(overridden)), event
102
+
103
+ async def claim_resume(
104
+ self, log_key: str, run_id: str, resumed: RunResumed, ctx: RunContext, origin: str
105
+ ) -> Event | None:
106
+ """Atomic for free: the status fold and ``_stamp`` are plain dict work with no suspension
107
+ point between them, so no other task can slip in and claim the same run.
108
+ """
109
+ if ctx.run_id != run_id:
110
+ raise ValueError(f"a claim on run {run_id!r} cannot be made in the context of {ctx.run_id!r}")
111
+ mine = [stored for stored in self._logs.get((ctx.namespace, log_key), ()) if stored.run_id == run_id]
112
+ if not can_resume(status_of(mine)):
113
+ return None
114
+ event = self._stamp(log_key, [resumed], ctx, origin)[0]
115
+ await asyncio.sleep(0)
116
+ return event
117
+
118
+ async def list_runs(self, ctx: RunContext, status: RunStatus | None = None) -> list[RunSummary]:
119
+ runs = [
120
+ (log_key, event.run_id)
121
+ for (namespace, log_key), log in self._logs.items()
122
+ if namespace == ctx.namespace
123
+ for event in log
124
+ if event.kind in LIFECYCLE_KINDS
125
+ ]
126
+ # One fold per run through the port's own projection rather than a second inline
127
+ # copy of it: re-walking a dev-sized dict is cheaper than two ways to derive status.
128
+ summaries = [
129
+ RunSummary(log_key=log_key, run_id=run_id, status=await self.run_status(log_key, run_id, ctx))
130
+ for log_key, run_id in dict.fromkeys(runs)
131
+ ]
132
+ return [summary for summary in summaries if status is None or summary.status is status]
133
+
134
+
135
+ # The unique-index equivalent this store used to need is gone: no caller supplies a ``seq``, and
136
+ # ``_stamp`` reads the run's last one and extends the log with no suspension in between, so two
137
+ # events at one ``seq`` is unconstructible rather than merely refused (ADR-D11 §6).
138
+
139
+
140
+ def _by_run(log: Sequence[Event]) -> dict[str, list[Event]]:
141
+ """One log's events split per run, each list still in append order."""
142
+ runs: dict[str, list[Event]] = {}
143
+ for event in log:
144
+ runs.setdefault(event.run_id, []).append(event)
145
+ return runs
146
+
147
+
148
+ __all__ = ["MemoryEventStore"]
@@ -0,0 +1,5 @@
1
+ """The event log in Postgres (ADR-D5: the platform record, not engine state)."""
2
+
3
+ from agentdeck.adapters.stores.postgres.store import PostgresEventStore
4
+
5
+ __all__ = ["PostgresEventStore"]