- Add EventType StrEnum with 38 typed domain event identifiers across 8 domains (plan lifecycle, decision, actor, tool, resource, sandbox, validation, session, budget) - Add frozen DomainEvent Pydantic model with event_type, auto-UTC timestamp, auto-ULID correlation_id, plan_id, root_plan_id, session_id, actor_name, project_name, and details fields - Add @runtime_checkable EventBus Protocol with emit() and subscribe() - Add ReactiveEventBus backed by RxPY Subject for in-process reactive streaming with synchronous handler dispatch and raw Observable stream - Add LoggingEventBus using structlog for audit-trail-only environments - Wire DecisionService to emit DECISION_CREATED on record_decision() - Wire PlanLifecycleService to emit PLAN_CREATED and PLAN_PHASE_CHANGED on use_action() and execute_plan() respectively - Register ReactiveEventBus as Singleton in DI container and inject into DecisionService and PlanLifecycleService providers - Add Behave BDD unit tests: 27 scenarios, 75 steps, 100% branch coverage - Add Robot Framework integration tests: 9 smoke cases - Add ASV performance benchmarks: 5 suites covering emit throughput and subscriber fan-out - Add reference documentation at docs/reference/event_bus.md ISSUES CLOSED: #473
6.6 KiB
Event Bus
The EventBus infrastructure provides a general-purpose domain event system for CleverAgents. Domain services emit typed events at significant state changes (plan creation, decision recording, phase transitions, etc.), enabling audit logging, reactive subscribers, and observability without coupling services to one another.
Overview
The system consists of four cooperating components:
| Component | Role |
|---|---|
EventType |
StrEnum of all domain event identifiers |
DomainEvent |
Frozen Pydantic model carrying event data |
EventBus |
Protocol that all bus implementations satisfy |
ReactiveEventBus |
RxPY-backed in-process bus (default) |
LoggingEventBus |
Structured-log-only bus (audit / test) |
EventType
cleveragents.infrastructure.events.EventType is a StrEnum whose members
cover all domains:
from cleveragents.infrastructure.events import EventType
print(EventType.PLAN_CREATED) # "plan.created"
print(EventType.DECISION_CREATED) # "decision.created"
print(EventType.ACTOR_COMPLETED) # "actor.completed"
Categories:
| Category | Examples |
|---|---|
| Plan lifecycle | plan.created, plan.phase_changed, plan.applied |
| Decision | decision.created, decision.superseded |
| Actor | actor.invoked, actor.completed, actor.errored |
| Tool | tool.invoked, tool.completed, tool.errored |
| Resource | resource.accessed, resource.modified |
| Sandbox | sandbox.created, checkpoint.restored |
| Validation | validation.started, validation.passed |
| Session | session.created, session.message_sent |
| Budget | budget.warning, budget.exceeded |
DomainEvent
All events are instances of the frozen Pydantic model
cleveragents.infrastructure.events.DomainEvent:
from cleveragents.infrastructure.events import DomainEvent, EventType
event = DomainEvent(
event_type=EventType.PLAN_CREATED,
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
actor_name="strategists/planner",
details={"phase": "strategize"},
)
| Field | Type | Default | Description |
|---|---|---|---|
event_type |
EventType |
required | The type of event |
timestamp |
datetime |
now(UTC) |
UTC time of emission |
correlation_id |
str |
auto ULID | Links related events in a request chain |
plan_id |
str | None |
None |
Associated plan ULID |
root_plan_id |
str | None |
None |
Root plan in a hierarchy |
session_id |
str | None |
None |
Associated session ULID |
actor_name |
str | None |
None |
Actor that emitted the event |
project_name |
str | None |
None |
Project context |
details |
dict |
{} |
Event-type-specific payload |
DomainEvent is immutable (frozen=True). Attempting to set an attribute
after construction raises a ValidationError.
EventBus Protocol
cleveragents.infrastructure.events.EventBus is a @runtime_checkable
structural protocol. Any object with emit() and subscribe() methods
satisfies it:
from cleveragents.infrastructure.events import EventBus, ReactiveEventBus
bus = ReactiveEventBus()
assert isinstance(bus, EventBus) # True
Interface:
class EventBus(Protocol):
def emit(self, event: DomainEvent) -> None: ...
def subscribe(self, event_type: EventType,
handler: Callable[[DomainEvent], None]) -> None: ...
ReactiveEventBus
cleveragents.infrastructure.events.ReactiveEventBus is the default
in-process implementation. It uses an RxPY Subject as its hot observable
backbone.
from cleveragents.infrastructure.events import (
DomainEvent, EventType, ReactiveEventBus,
)
bus = ReactiveEventBus()
# Subscribe a plain callback
bus.subscribe(EventType.PLAN_CREATED, lambda e: print("Plan created:", e.plan_id))
# Use the raw RxPY stream for advanced operators
bus.stream.pipe(
rx.operators.filter(lambda e: e.plan_id is not None),
).subscribe(lambda e: print("Plan event:", e))
# Emit an event
bus.emit(DomainEvent(event_type=EventType.PLAN_CREATED, plan_id="01ABC..."))
emit(event)pushes to the RxPY Subject and calls all type-specific handlers synchronously.subscribe(event_type, handler)registers a callback for one event type.streamexposes the rawrx.Observablefor RxPY operators.
LoggingEventBus
cleveragents.infrastructure.events.LoggingEventBus logs every event to
structlog at INFO level. It is useful for audit trails, CI pipelines, and
environments where RxPY overhead is undesirable.
from cleveragents.infrastructure.events import LoggingEventBus, EventType, DomainEvent
bus = LoggingEventBus()
bus.emit(DomainEvent(event_type=EventType.DECISION_CREATED, plan_id="01ABC..."))
# → structured log: {"event": "domain_event", "event_type": "decision.created", ...}
Dependency Injection
The DI container (cleveragents.application.container.Container) registers
ReactiveEventBus as a Singleton provider named event_bus. It is
automatically wired into DecisionService and PlanLifecycleService:
from cleveragents.application.container import Container
container = Container()
bus = container.event_bus() # ReactiveEventBus singleton
To substitute a different implementation (e.g. in tests):
from cleveragents.application.container import Container
from cleveragents.infrastructure.events import LoggingEventBus
from dependency_injector import providers
container = Container()
container.event_bus.override(providers.Singleton(LoggingEventBus))
Service Event Emission
DecisionService
cleveragents.application.services.decision_service.DecisionService.record_decision
emits DECISION_CREATED after a decision is persisted.
PlanLifecycleService
| Method | Event emitted |
|---|---|
use_action(...) |
PLAN_CREATED |
execute_plan(...) |
PLAN_PHASE_CHANGED |
Both services accept an optional event_bus constructor parameter; when
None, event emission is silently skipped (backward-compatible).
Example: Subscribing to Plan Events
from cleveragents.application.container import Container
from cleveragents.infrastructure.events import EventType, DomainEvent
container = Container()
bus = container.event_bus()
def on_plan_created(event: DomainEvent) -> None:
print(f"New plan: {event.plan_id} | details: {event.details}")
bus.subscribe(EventType.PLAN_CREATED, on_plan_created)
# All subsequent plan.create operations will call on_plan_created
svc = container.plan_lifecycle_service()