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
|
@@ -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,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,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"]
|