Files
cleveragents-core/docs/reference/event_bus.md
T
aditya d17895dae2 feat(events): add EventBus protocol and ReactiveEventBus implementation
- 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
2026-03-02 11:54:53 +00:00

221 lines
6.6 KiB
Markdown

# 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:
```python
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`:
```python
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:
```python
from cleveragents.infrastructure.events import EventBus, ReactiveEventBus
bus = ReactiveEventBus()
assert isinstance(bus, EventBus) # True
```
**Interface:**
```python
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.
```python
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.
- `stream` exposes the raw `rx.Observable` for 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.
```python
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`:
```python
from cleveragents.application.container import Container
container = Container()
bus = container.event_bus() # ReactiveEventBus singleton
```
To substitute a different implementation (e.g. in tests):
```python
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
```python
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()
```