Files
hive/core/framework/runtime/event_bus.py
T
2026-02-03 12:14:13 -08:00

734 lines
21 KiB
Python

"""
Event Bus - Pub/sub event system for inter-stream communication.
Allows streams to:
- Publish events about their execution
- Subscribe to events from other streams
- Coordinate based on shared state changes
"""
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import Any
logger = logging.getLogger(__name__)
class EventType(StrEnum):
"""Types of events that can be published."""
# Execution lifecycle
EXECUTION_STARTED = "execution_started"
EXECUTION_COMPLETED = "execution_completed"
EXECUTION_FAILED = "execution_failed"
EXECUTION_PAUSED = "execution_paused"
EXECUTION_RESUMED = "execution_resumed"
# State changes
STATE_CHANGED = "state_changed"
STATE_CONFLICT = "state_conflict"
# Goal tracking
GOAL_PROGRESS = "goal_progress"
GOAL_ACHIEVED = "goal_achieved"
CONSTRAINT_VIOLATION = "constraint_violation"
# Stream lifecycle
STREAM_STARTED = "stream_started"
STREAM_STOPPED = "stream_stopped"
# Node event-loop lifecycle
NODE_LOOP_STARTED = "node_loop_started"
NODE_LOOP_ITERATION = "node_loop_iteration"
NODE_LOOP_COMPLETED = "node_loop_completed"
# LLM streaming observability
LLM_TEXT_DELTA = "llm_text_delta"
LLM_REASONING_DELTA = "llm_reasoning_delta"
# Tool lifecycle
TOOL_CALL_STARTED = "tool_call_started"
TOOL_CALL_COMPLETED = "tool_call_completed"
# Client I/O (client_facing=True nodes only)
CLIENT_OUTPUT_DELTA = "client_output_delta"
CLIENT_INPUT_REQUESTED = "client_input_requested"
# Internal node observability (client_facing=False nodes)
NODE_INTERNAL_OUTPUT = "node_internal_output"
NODE_INPUT_BLOCKED = "node_input_blocked"
NODE_STALLED = "node_stalled"
# Custom events
CUSTOM = "custom"
@dataclass
class AgentEvent:
"""An event in the agent system."""
type: EventType
stream_id: str
node_id: str | None = None # Which node emitted this event
execution_id: str | None = None
data: dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
correlation_id: str | None = None # For tracking related events
def to_dict(self) -> dict:
"""Convert to dictionary for serialization."""
return {
"type": self.type.value,
"stream_id": self.stream_id,
"node_id": self.node_id,
"execution_id": self.execution_id,
"data": self.data,
"timestamp": self.timestamp.isoformat(),
"correlation_id": self.correlation_id,
}
# Type for event handlers
EventHandler = Callable[[AgentEvent], Awaitable[None]]
@dataclass
class Subscription:
"""A subscription to events."""
id: str
event_types: set[EventType]
handler: EventHandler
filter_stream: str | None = None # Only receive events from this stream
filter_node: str | None = None # Only receive events from this node
filter_execution: str | None = None # Only receive events from this execution
class EventBus:
"""
Pub/sub event bus for inter-stream communication.
Features:
- Async event handling
- Type-based subscriptions
- Stream/execution filtering
- Event history for debugging
Example:
bus = EventBus()
# Subscribe to execution events
async def on_execution_complete(event: AgentEvent):
print(f"Execution {event.execution_id} completed")
bus.subscribe(
event_types=[EventType.EXECUTION_COMPLETED],
handler=on_execution_complete,
)
# Publish an event
await bus.publish(AgentEvent(
type=EventType.EXECUTION_COMPLETED,
stream_id="webhook",
execution_id="exec_123",
data={"result": "success"},
))
"""
def __init__(
self,
max_history: int = 1000,
max_concurrent_handlers: int = 10,
):
"""
Initialize event bus.
Args:
max_history: Maximum events to keep in history
max_concurrent_handlers: Maximum concurrent handler executions
"""
self._subscriptions: dict[str, Subscription] = {}
self._event_history: list[AgentEvent] = []
self._max_history = max_history
self._semaphore = asyncio.Semaphore(max_concurrent_handlers)
self._subscription_counter = 0
self._lock = asyncio.Lock()
def subscribe(
self,
event_types: list[EventType],
handler: EventHandler,
filter_stream: str | None = None,
filter_node: str | None = None,
filter_execution: str | None = None,
) -> str:
"""
Subscribe to events.
Args:
event_types: Types of events to receive
handler: Async function to call when event occurs
filter_stream: Only receive events from this stream
filter_node: Only receive events from this node
filter_execution: Only receive events from this execution
Returns:
Subscription ID (use to unsubscribe)
"""
self._subscription_counter += 1
sub_id = f"sub_{self._subscription_counter}"
subscription = Subscription(
id=sub_id,
event_types=set(event_types),
handler=handler,
filter_stream=filter_stream,
filter_node=filter_node,
filter_execution=filter_execution,
)
self._subscriptions[sub_id] = subscription
logger.debug(f"Subscription {sub_id} registered for {event_types}")
return sub_id
def unsubscribe(self, subscription_id: str) -> bool:
"""
Unsubscribe from events.
Args:
subscription_id: ID returned from subscribe()
Returns:
True if subscription was found and removed
"""
if subscription_id in self._subscriptions:
del self._subscriptions[subscription_id]
logger.debug(f"Subscription {subscription_id} removed")
return True
return False
async def publish(self, event: AgentEvent) -> None:
"""
Publish an event to all matching subscribers.
Args:
event: Event to publish
"""
# Add to history
async with self._lock:
self._event_history.append(event)
if len(self._event_history) > self._max_history:
self._event_history = self._event_history[-self._max_history :]
# Find matching subscriptions
matching_handlers: list[EventHandler] = []
for subscription in self._subscriptions.values():
if self._matches(subscription, event):
matching_handlers.append(subscription.handler)
# Execute handlers concurrently
if matching_handlers:
await self._execute_handlers(event, matching_handlers)
def _matches(self, subscription: Subscription, event: AgentEvent) -> bool:
"""Check if a subscription matches an event."""
# Check event type
if event.type not in subscription.event_types:
return False
# Check stream filter
if subscription.filter_stream and subscription.filter_stream != event.stream_id:
return False
# Check node filter
if subscription.filter_node and subscription.filter_node != event.node_id:
return False
# Check execution filter
if subscription.filter_execution and subscription.filter_execution != event.execution_id:
return False
return True
async def _execute_handlers(
self,
event: AgentEvent,
handlers: list[EventHandler],
) -> None:
"""Execute handlers concurrently with rate limiting."""
async def run_handler(handler: EventHandler) -> None:
async with self._semaphore:
try:
await handler(event)
except Exception as e:
logger.error(f"Handler error for {event.type}: {e}")
# Run all handlers concurrently
await asyncio.gather(*[run_handler(h) for h in handlers], return_exceptions=True)
# === CONVENIENCE PUBLISHERS ===
async def emit_execution_started(
self,
stream_id: str,
execution_id: str,
input_data: dict[str, Any] | None = None,
correlation_id: str | None = None,
) -> None:
"""Emit execution started event."""
await self.publish(
AgentEvent(
type=EventType.EXECUTION_STARTED,
stream_id=stream_id,
execution_id=execution_id,
data={"input": input_data or {}},
correlation_id=correlation_id,
)
)
async def emit_execution_completed(
self,
stream_id: str,
execution_id: str,
output: dict[str, Any] | None = None,
correlation_id: str | None = None,
) -> None:
"""Emit execution completed event."""
await self.publish(
AgentEvent(
type=EventType.EXECUTION_COMPLETED,
stream_id=stream_id,
execution_id=execution_id,
data={"output": output or {}},
correlation_id=correlation_id,
)
)
async def emit_execution_failed(
self,
stream_id: str,
execution_id: str,
error: str,
correlation_id: str | None = None,
) -> None:
"""Emit execution failed event."""
await self.publish(
AgentEvent(
type=EventType.EXECUTION_FAILED,
stream_id=stream_id,
execution_id=execution_id,
data={"error": error},
correlation_id=correlation_id,
)
)
async def emit_goal_progress(
self,
stream_id: str,
progress: float,
criteria_status: dict[str, Any],
) -> None:
"""Emit goal progress event."""
await self.publish(
AgentEvent(
type=EventType.GOAL_PROGRESS,
stream_id=stream_id,
data={
"progress": progress,
"criteria_status": criteria_status,
},
)
)
async def emit_constraint_violation(
self,
stream_id: str,
execution_id: str,
constraint_id: str,
description: str,
) -> None:
"""Emit constraint violation event."""
await self.publish(
AgentEvent(
type=EventType.CONSTRAINT_VIOLATION,
stream_id=stream_id,
execution_id=execution_id,
data={
"constraint_id": constraint_id,
"description": description,
},
)
)
async def emit_state_changed(
self,
stream_id: str,
execution_id: str,
key: str,
old_value: Any,
new_value: Any,
scope: str,
) -> None:
"""Emit state changed event."""
await self.publish(
AgentEvent(
type=EventType.STATE_CHANGED,
stream_id=stream_id,
execution_id=execution_id,
data={
"key": key,
"old_value": old_value,
"new_value": new_value,
"scope": scope,
},
)
)
# === NODE EVENT-LOOP PUBLISHERS ===
async def emit_node_loop_started(
self,
stream_id: str,
node_id: str,
execution_id: str | None = None,
max_iterations: int | None = None,
) -> None:
"""Emit node loop started event."""
await self.publish(
AgentEvent(
type=EventType.NODE_LOOP_STARTED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"max_iterations": max_iterations},
)
)
async def emit_node_loop_iteration(
self,
stream_id: str,
node_id: str,
iteration: int,
execution_id: str | None = None,
) -> None:
"""Emit node loop iteration event."""
await self.publish(
AgentEvent(
type=EventType.NODE_LOOP_ITERATION,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"iteration": iteration},
)
)
async def emit_node_loop_completed(
self,
stream_id: str,
node_id: str,
iterations: int,
execution_id: str | None = None,
) -> None:
"""Emit node loop completed event."""
await self.publish(
AgentEvent(
type=EventType.NODE_LOOP_COMPLETED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"iterations": iterations},
)
)
# === LLM STREAMING PUBLISHERS ===
async def emit_llm_text_delta(
self,
stream_id: str,
node_id: str,
content: str,
snapshot: str,
execution_id: str | None = None,
) -> None:
"""Emit LLM text delta event."""
await self.publish(
AgentEvent(
type=EventType.LLM_TEXT_DELTA,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"content": content, "snapshot": snapshot},
)
)
async def emit_llm_reasoning_delta(
self,
stream_id: str,
node_id: str,
content: str,
execution_id: str | None = None,
) -> None:
"""Emit LLM reasoning delta event."""
await self.publish(
AgentEvent(
type=EventType.LLM_REASONING_DELTA,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"content": content},
)
)
# === TOOL LIFECYCLE PUBLISHERS ===
async def emit_tool_call_started(
self,
stream_id: str,
node_id: str,
tool_use_id: str,
tool_name: str,
tool_input: dict[str, Any] | None = None,
execution_id: str | None = None,
) -> None:
"""Emit tool call started event."""
await self.publish(
AgentEvent(
type=EventType.TOOL_CALL_STARTED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={
"tool_use_id": tool_use_id,
"tool_name": tool_name,
"tool_input": tool_input or {},
},
)
)
async def emit_tool_call_completed(
self,
stream_id: str,
node_id: str,
tool_use_id: str,
tool_name: str,
result: str = "",
is_error: bool = False,
execution_id: str | None = None,
) -> None:
"""Emit tool call completed event."""
await self.publish(
AgentEvent(
type=EventType.TOOL_CALL_COMPLETED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={
"tool_use_id": tool_use_id,
"tool_name": tool_name,
"result": result,
"is_error": is_error,
},
)
)
# === CLIENT I/O PUBLISHERS ===
async def emit_client_output_delta(
self,
stream_id: str,
node_id: str,
content: str,
snapshot: str,
execution_id: str | None = None,
) -> None:
"""Emit client output delta event (client_facing=True nodes)."""
await self.publish(
AgentEvent(
type=EventType.CLIENT_OUTPUT_DELTA,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"content": content, "snapshot": snapshot},
)
)
async def emit_client_input_requested(
self,
stream_id: str,
node_id: str,
prompt: str = "",
execution_id: str | None = None,
) -> None:
"""Emit client input requested event (client_facing=True nodes)."""
await self.publish(
AgentEvent(
type=EventType.CLIENT_INPUT_REQUESTED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"prompt": prompt},
)
)
# === INTERNAL NODE PUBLISHERS ===
async def emit_node_internal_output(
self,
stream_id: str,
node_id: str,
content: str,
execution_id: str | None = None,
) -> None:
"""Emit node internal output event (client_facing=False nodes)."""
await self.publish(
AgentEvent(
type=EventType.NODE_INTERNAL_OUTPUT,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"content": content},
)
)
async def emit_node_stalled(
self,
stream_id: str,
node_id: str,
reason: str = "",
execution_id: str | None = None,
) -> None:
"""Emit node stalled event."""
await self.publish(
AgentEvent(
type=EventType.NODE_STALLED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"reason": reason},
)
)
async def emit_node_input_blocked(
self,
stream_id: str,
node_id: str,
prompt: str = "",
execution_id: str | None = None,
) -> None:
"""Emit node input blocked event."""
await self.publish(
AgentEvent(
type=EventType.NODE_INPUT_BLOCKED,
stream_id=stream_id,
node_id=node_id,
execution_id=execution_id,
data={"prompt": prompt},
)
)
# === QUERY OPERATIONS ===
def get_history(
self,
event_type: EventType | None = None,
stream_id: str | None = None,
execution_id: str | None = None,
limit: int = 100,
) -> list[AgentEvent]:
"""
Get event history with optional filtering.
Args:
event_type: Filter by event type
stream_id: Filter by stream
execution_id: Filter by execution
limit: Maximum events to return
Returns:
List of matching events (most recent first)
"""
events = self._event_history[::-1] # Reverse for most recent first
# Apply filters
if event_type:
events = [e for e in events if e.type == event_type]
if stream_id:
events = [e for e in events if e.stream_id == stream_id]
if execution_id:
events = [e for e in events if e.execution_id == execution_id]
return events[:limit]
def get_stats(self) -> dict:
"""Get event bus statistics."""
type_counts = {}
for event in self._event_history:
type_counts[event.type.value] = type_counts.get(event.type.value, 0) + 1
return {
"total_events": len(self._event_history),
"subscriptions": len(self._subscriptions),
"events_by_type": type_counts,
}
# === WAITING OPERATIONS ===
async def wait_for(
self,
event_type: EventType,
stream_id: str | None = None,
node_id: str | None = None,
execution_id: str | None = None,
timeout: float | None = None,
) -> AgentEvent | None:
"""
Wait for a specific event to occur.
Args:
event_type: Type of event to wait for
stream_id: Filter by stream
node_id: Filter by node
execution_id: Filter by execution
timeout: Maximum time to wait (seconds)
Returns:
The event if received, None if timeout
"""
result: AgentEvent | None = None
event_received = asyncio.Event()
async def handler(event: AgentEvent) -> None:
nonlocal result
result = event
event_received.set()
# Subscribe
sub_id = self.subscribe(
event_types=[event_type],
handler=handler,
filter_stream=stream_id,
filter_node=node_id,
filter_execution=execution_id,
)
try:
# Wait with timeout
if timeout:
try:
await asyncio.wait_for(event_received.wait(), timeout=timeout)
except TimeoutError:
return None
else:
await event_received.wait()
return result
finally:
self.unsubscribe(sub_id)