Oneunified event stream,
for the entire framework.
Signals that something happened in the framework are currently spread across six disconnected mechanisms. This layer unifies them through one bus:sources emit and consumers subscribe. Proactive is only its first consumer.
✅ Implemented (migration steps 1–2): bus · class-A taps · file.changed · tool gate | Class-B bridges installed | Observation: events.jsonl always enabled
Architecture overview
All components run in the same worker process(as daemon threads), so the bus is a process-level singleton. Event flow:
Event model: three core fields plus open metadata
The core (event type, content and time) is fixed; correlation information goes into open metadata,not hard-coded fields.
They are external correlations rather than intrinsic event properties. They are meaningless for half the events, such as auth and channel; dedicated fields would require nullable values. Mature event systems such as DOM, logs, MQ and tracing use a fixed core with correlations in labels or headers.
There is no Turn object in the framework; a turn is the assistant message id passed through a ContextVar (
_current_turn_id). Agent emit sites include it in metadata when available; auth / channel emit sites have no value and therefore no turn metadata.Two event-source classes
It is easy to focus only on class A, but class B events outside the agent loop often matter more for proactive behavior. Both classes share one bus, and open metadata accommodates them naturally.
Agent activity events
System-state events
Event types (first version)
Selected moments that consumers want to respond to, rather than every framework action. Future types are additive (see 07).
| TYPE | When | Existing source | |
|---|---|---|---|
| A | user.prompt_submitted | User sends a message | Dispatcher entry |
| A | model.response_started / .completed | Model starts / completes a response | AgentEventMessageStart/End |
| A | tool.before | Tool about to execute (interceptable) | AgentEventToolStart |
| A | tool.after | Tool completed | AgentEventToolEnd |
| A | file.changedNew | File changed | Attach to checkpoint_before_edit |
| A | turn.ended | Turn completion | AgentEventTurnEnd |
| A | subagent.started / .ended | Subtask start/end | JobRunner broadcast |
| B | credential.cooldown / .exhausted / .rotated | Credential rate limit / pool exhaustion / rotation | AuthStore._emit |
| B | context.compacted | Context compacted | context/engine.py |
| B | channel.message_inbound | Incoming external messages | channels/_broadcast.py |
| B | skills.changed / plugins.update_available | Skill changes / new plugin version | webui watcher |
Bus: one process-level singleton
webui, agent loop, channels, memory, auth, task runner Everything runs in the same worker process. Follow the existing get_store() / get_runner() double-checked locking singleton pattern and reuse the unused agent/event_bus.py. No cross-process bridge is needed.
def get_event_bus() -> EventBus: …
class EventBus:
def emit(self, event: Event): …
# Broadcast, fire-and-forget, without blocking the caller
def subscribe(self, handler, *, types=None): …
# Subscribe by event type and receive only relevant types
Observation vs. interception
Observation
Default · AsynchronousAfter emit, subscribers receive events asynchronously; the source does not wait.
- All events use this path
- Slow subscribersmust not slow the framework
Interception
tool.before only · SynchronousBefore a tool executes, downstream code can refuse execution. Attach to the single tool entry point _execute_tool_calls before its tool.execute() call.
- Must be fast: no LLM calls
- When responses differ, use the strictest:Block > Confirm > Allow; Confirm reuses existing _approval
- Also applies to subagents, independently of permission_mode=bypass; otherwise dangerous actions could bypass the gate through subtasks
Two principles to retain
An event requires a consumer that wants to respond
Not every call is an event. The type list isselective: imminent tool execution and credential rate limits are events; an internal list concatenation is not. Emitting every action is a common cause of event-system degradation.
Evolve additively
Sources and consumers do not know each other, soadding an event changes only its emit site: add one line to a framework function, or one emit at a shared entry for a class of actions. Adding types or payload fields is safe;changing existing structures can break existing subscribers. This is why payload and metadata use open dictionaries.
openprogram/events/{registry,bus,bridges}.py and actual emit sites; file:line references in the event table must stay synchronized with those implementations. The proactive rule layer remains a separate deferred design.