feat(runtime): Runtime, memory store, stub engine, contract suite - #51
Conversation
The first moving part: Runtime.run() looks up an invocable, gets an event iterator from an engine, and per event stamps the envelope, appends to the log, fans out to sinks, then yields — persist-before-yield, in that order. Adds the pieces it needs and nothing else: RunContext, InvocableSpec, the first three ports (EnginePort, SessionStorePort, EventSinkPort), a memory event log, and StubEngine — which is not a placeholder but the reference implementation of the engine contract, playing scripted event sequences so the invariants can be tested with no model, network or clock in the way. The deliverable is tests/contract/: the cross-engine invariant suite, parametrized over a flat list of cases each engine appends to. It asserts run.started first at seq 0, contiguous seq, exactly one terminal event and it is last, persist-before-yield checked at every step, the envelope coming from the context rather than the engine, and an abandoned stream leaving the log intact. Two import-linter contracts activated: the Runtime imports core only, and the two pure adapters stay pure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces the first runnable v2 “use-case layer” runtime loop (persist-before-yield), plus a pure in-memory event store, a scripted stub engine, and a contract test suite that codifies cross-engine invariants for event streams.
Changes:
- Add
Runtime.run()orchestration (emitrun.started, stamp envelopes, append→fan-out→yield, engine failure handling, sink fan-out semantics). - Add core v2 building blocks (
RunContext,InvocableSpec, and engine/store/sink ports) plus pure adapters (MemoryEventStore,StubEngine). - Add a contract suite (
tests/contract/) and targeted runtime/store tests to lock in invariants and edge cases.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| agentdeck/runtime/service.py | Implements the runtime event loop, envelope stamping, store persistence, sink fan-out, and engine termination guards. |
| agentdeck/core/context.py | Adds RunContext (frozen) and log_key behavior (session-or-run keying). |
| agentdeck/core/invocable.py | Adds InvocableSpec / InvocableKind for engine-neutral invocable selection. |
| agentdeck/core/ports/engine.py | Defines EnginePort contract: engines yield payloads only. |
| agentdeck/core/ports/store.py | Defines SessionStorePort event-log contract keyed by log_key, inclusive ranged reads. |
| agentdeck/core/ports/sink.py | Defines EventSinkPort for fire-and-forget stream taps. |
| agentdeck/core/ports/init.py | Re-exports ports for ergonomic imports. |
| agentdeck/core/init.py | Re-exports RunContext, invocable types alongside existing schema exports. |
| agentdeck/adapters/engines/stub/init.py | Adds StubEngine + stub_spec scripted engine for contract testing and deterministic runs. |
| agentdeck/adapters/stores/memory/init.py | Adds MemoryEventStore keyed by (tenant, log_key) for isolation and deterministic tests. |
| agentdeck/adapters/init.py | Documents adapter ring boundaries. |
| agentdeck/adapters/engines/init.py | Documents engine adapter family. |
| agentdeck/adapters/stores/init.py | Documents store adapter family. |
| tests/contract/cases.py | Defines the flat CASES list and harness types (Case, Played) for contract runs. |
| tests/contract/conftest.py | Wires a per-case runtime + memory store + frozen clock fixtures for contract execution. |
| tests/contract/test_event_stream.py | Adds cross-engine invariants: run.started at seq 0, contiguous seq, terminal/suspended rules, persist-before-yield, etc. |
| tests/test_runtime_service.py | Adds runtime-focused tests: resolution failures, injected clock, sessionless persistence, sink behavior, exception recording/propagation, engine history passing. |
| tests/test_memory_store.py | Adds store-focused tests: append ordering, inclusive from_seq, tenant isolation, read immutability, payload round-tripping. |
| pyproject.toml | Extends pytest pythonpath for contract suite imports (cases). |
| .importlinter | Adds contracts preventing runtime/service from importing adapters/surfaces and keeping pure adapters dependency-clean. |
| docs/design/agentdeck-v2-architecture.md | Adds “as built” amendments describing the realized runtime/store/port shapes (note: date issue flagged in review). |
| CHANGELOG.md | Records the new runtime/core ports/adapters/contract suite as Unreleased changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Review findings on #51. A seq range over a whole log spliced together the tail of every run in it, because seq restarts at 0 per run while log_key is the session. The store now reads either a whole log (read) or one run's inclusive range (read_run) — the latter is what a consumer calls to refetch after a gap, and it has to name the run for the range to mean anything. The Runtime guarded "no terminal event" but not "exactly one, and last": an engine yielding anything after a terminal payload got a second one appended, producing a log that said the run both completed and failed. A terminal payload now ends the read, so terminal-is-last holds by construction and stray payloads are discarded. Added the third misbehaving-engine case to the contract suite to prove it. An abandoned stream left the run open in the log, indistinguishable from one still in flight — the failure the no-terminal guard exists to prevent, arriving by another door. The consumer walking away now records run.cancelled, and the contract suite asserts the run is closed rather than merely contiguous. Also: EventSinkPort documents that emit is concurrent and unordered (and the test sink now awaits before recording, so it can only pass if that holds); Runtime.drain() awaits in-flight emits so a shutdown doesn't destroy the last audit events; EnginePort.start returns an AsyncGenerator, since the Runtime closes the stream when it stops reading early; the memory store refuses an event stamped for another tenant; the stub raises ConfigError rather than a bare TypeError for a missing script; and the unbounded per-run history read carries a ponytail comment naming the windowing trigger. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All seven addressed in 1. I did consider deleting 2. Terminal-is-last now holds by construction. Took your snippet:
3. Abandonment — fixed, not deferred. Your framing convinced me: "run left open in the log" is one failure mode with three doors, and I'd only closed two. 4. Sink concurrency — documented on the port, and the test sink now earns its assertion. 5. 6. 7. Nits — all taken: Gate: ruff + ruff-format clean, Design doc amendments updated in place rather than added to: §4.5 now records the One thing I left alone deliberately: |
Milestone-0 Step 2 — "Runtime + memory store + stub engine. The stamp/append/fan-out/yield loop against a scripted stub engine; contract-suite skeleton with the first invariants." The rest of epic Story 1 (
status.py,ControlPort, threadingRunContextthroughApp) is deliberately not here — see the ledger.Summary
Runtime.run()resolves an invocable, opens the run withrun.started, iterates the engine's payloads, and for each one: stamps the envelope → appends to the log → fans out to sinks → yields. The order is the contract, so an event a consumer has seen is already persisted and a consumer that spots aseqgap can always refetch it.StubEngineis the reference implementation ofEnginePort, not a placeholder: a spec'snativeis a script of payloads to yield, with any exception in the sequence raised where it sits. That reproduces every way a run can end — completes, fails mid-stream, interrupts, or stops without a terminal event — with no model, network, or clock, which is why it stays the contract suite's fastest engine.tests/contract/is the deliverable. One flatCASESlist; adding an engine means appending its cases and inheriting every invariant unchanged.Production files touched
New:
core/context.py,core/invocable.py,core/ports/{__init__,engine,store,sink}.py,runtime/service.py,adapters/{__init__,engines/__init__,engines/stub/__init__,stores/__init__,stores/memory/__init__}.py.Modified:
core/__init__.py(re-exports),.importlinter(two contracts),pyproject.toml(pytestpythonpath),CHANGELOG.md,docs/design/agentdeck-v2-architecture.md(three dated amendments).Nothing in v1 changed.
agentdeck/runtime/__init__.pyis untouched, so importing the Runtime adds no new coupling to v1 exports — but note that the package__init__still pulls in v1 settings/workspace transitively; Story 2 cleans that.Judgment ledger
What the Runtime does beyond the design sketch
run.starteditself, atseq0. The payload's context snapshot isRunContextdata; no engine should be trusted to copy it, and this guarantees the run's join point is always seq 0. Engines must not emit it (documented onEnginePort); a second one would show up as a contract-suite failure.run.failedrecorded for it. A run left open hangs every consumer forever, socheck_terminalholding is worth three lines here.SUSPENDED_KINDS = {"run.interrupted", "run.paused"}lives inruntime/service.py, notcore/events.py— the Runtime is the only thing that cares, and I did not want a schema-file edit in a non-schema PR (§7).run.failed.messagecarries the exception type name and the engine, neverstr(exc). An exception message can hold request content or a secret, and events go to sinks and (later) over SSE — this matches whattests/golden/test_golden_wire.py::test_failures_never_echo_the_error_messagealready guarantees at the HTTP edge. The full traceback goes to the log at ERROR withexc_info. Tested.raiseafter recording). §5: the event is the record, the exception is the caller's — both, always.raisehere is correct anderrors.pyneeded no new class.log_key = session_id or run_id(a property onRunContext). Without it a sessionless run's events go nowhere and persist-before-yield quietly stops applying to one-off runs. This renamed the store port's parameter fromsession_id— amended in the design doc.from_seq: int = 0, inclusive, replacing the design'safter_seq: int = 0, which would have excluded event 0 given contiguous-from-0seq. Amended in the design doc.create_taskper sink per event, held in a set, exceptions logged and dropped. Never awaited, so a slow sink cannot pin the run (NFR-6, tested with a sink that never returns). The set is the reference-holder so the loop cannot collect a task mid-emit; §6's "tasks have owners" is satisfied by that plus the swallowing wrapper, not by a join. Ceiling noted in a comment: unbounded set, bounded per-sink queue if one ever piles up.clockis injected (Callable[[], datetime], defaulting todatetime.now(UTC)) so tests need no wall clock (§8).MemoryEventStorekeys on(tenant, log_key). Two tenants may pick the same session id; isolation is not something a store gets to skip (§12). Tested.What I deliberately did not build
RunContext.gateand.caps— the two fields that are behavior rather than data. Nothing callscheckpoint()orrequire()until Stories 3 and 4, which build the things those fields would point at. Every data field is there, plusparent_run_idandtriggered_bysorun.startedcan be filled from the context alone.principalis astr(the schema'sRunContextSnapshot.principalis astr); aPrincipaldataclass arrives with an auth story.EnginePort.resume()and.supports()— resume needs a checkpointer and thread-id semantics that do not exist yet;supports()exists for capability checks (Story 4). Selection is onengine: ClassVar[str]today. Amended in the design doc.core/status.py,ControlPort— a status machine with no signal source has no test that isn't tautological. They land together in Story 3. This is the main thing left of epic Story 1's acceptance list.InvocableRegistry—Runtimetakes aMapping[str, InvocableSpec]. A dict already has the one method the Runtime needs; the registry arrives with discovery in Story 2.originisspec.namefor every event in the run. Per-payload attribution (an openai-agents handoff, where a sub-agent's events need their own origin) needs the port to carry it, and lands with the adapter that has the first real case — UC1, Step 3. Flagging it because UC1's falsifier is exactly "the renderer distinguishes speakers using onlyorigin+message_id".adapters/<family>/<name>/as packages with the class in__init__.py, per the design's target layout. Modules would have been the same file count once the family directories exist, so there was nothing to gain by diverging.InvocableSpec.capabilities— Story 4 ownsCapabilityRequest.metadataandnativecover the stub's needs.Tests
CASESlist intests/contract/cases.py, not a per-engine class. EachCasedeclaresends: "terminal" | "suspended"so the suite knows which invariant to hold it to; the tests see nothing else about the engine. Adding LangGraph in Story 2 means appending its cases.cases.py, notconftest.py, so the test modules can importCase/Playedfor annotations —import conftestis not safe across test directories (the basename collision that bittests/goldenin PR feat(core): canonical event schema v1 #49).tests/contractis onpythonpathexplicitly, matching the existingtests/goldenrationale.raises-midstream,stops-without-a-terminal-event) — the terminal-event invariant has to hold for them too, which is what proves the Runtime's guards rather than the stub's good manners.> 500 changed lines(1105). Production is ~450 and within the cap; the rest is the test suite this story exists to produce. The available slices — a Runtime with no engine, or a store with no caller — are untestable halves, so I declared the overage instead of splitting into unverifiable PRs (§13).Test evidence CI cannot show
The two new import contracts bite. Added
from agentdeck.adapters.stores.memory import MemoryEventStoretoruntime/service.pyandimport redisto the memory store, then ranlint-imports:Reverted;
4 kept, 0 broken. The existing AST test (tests/core/test_import_law.py) alreadyrglobs core, so it coverscore/ports/without a change.Gate:
ruff check+ruff format --checkclean,ty check agentdeckclean,lint-imports4 kept / 0 broken,pytest tests/→ 193 passed, 5 skipped (was 135; the 5 skips are theends-conditional invariant pair). Goldens untouched — no schema change, sotests/core/snapshots/andtests/golden/snapshots/are byte-identical.Doc amendments
Three dated
as builtnotes indocs/design/agentdeck-v2-architecture.md: §4.3 (whichRunContextfields exist and whygate/capswait), §4.5 (log_key/from_seq,EnginePortshipping withstartonly, unbuilt ports), §4.6 (the constructor as built, plus the two behaviors the sketch omits — Runtime-emittedrun.startedand the engine-left-open guard).Milestone-0 Step 2 gate
Both green — the second is
test_an_abandoned_stream_leaves_the_store_intact, which closes the generator mid-run and asserts the log is a contiguous prefix opening withrun.started.