d17895dae2
- 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
221 lines
6.6 KiB
Markdown
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()
|
|
```
|