DESIGN · EVENT LAYER

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

AgentEvent streamauth._emiton_event callback WS broadcastPeriodic pollingLogs only
Current state: six separate mechanisms; integrating a trigger first requires identifying which mechanism owns it
⟶
EventBus · emit / subscribe
Unified: one bus and one integration method
01 · ARCHITECTURE

Architecture overview

All components run in the same worker process(as daemon threads), so the bus is a process-level singleton. Event flow:

WORKER PROCESS · SINGLE PROCESS · MULTIPLE DAEMON THREADS CLASS A · AGENT ACTIVITY A agent loop Model response · Before/after tools · File changes A task runner Subtask start/end CLASS B · SYSTEM STATE B auth Credential rate limits / rotation B context Compaction thresholds 70% / 80% B channels Incoming external messages B memory Idle processing start/end emit(Event) EventBus Process-level singleton get_event_bus() emit(Event) subscribe(types=…) Sources and consumers do not know each other They only know the bus Observe · Asynchronous · Does not block agents webui server Subscribe → Forward to frontend WebSocket proactive First consumer · Rule layer (separate design) Intercept · Synchronous · tool.before only tool.before query point Reuse _approval · Block / Confirm / Allow Also applies to subagents WS Frontend / TUI Browser · Command line (outside the worker process)
Class-A events · Observation (asynchronous) Class-B events · Observation (asynchronous) Interception (synchronous, tool.before only)
02 · EVENT

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.

Event · dataclassfrozen=True · Immutable after creation
CORE · FIXED
idUnique identifier tsOccurrence time typeEvent type originuser / agent / tool / system / proactive
Content
payload : dictCommand, file path, rate-limited account, etc.
OPEN METADATA · ADD WHEN NEEDED
metadata : dict{"session":…, "turn":…, "lane":…}
Why are turn / session metadata rather than 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.
This layer does not model turns.
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.
03 · SOURCES

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.

CLASS A

Agent activity events

Occur during agent work
User messageModel responseBefore/after toolsFile changesTurn completionSubtask start/end
For proactive behavior:foundational. Metadata usually includes session / turn / lane.
CLASS B

System-state events

Global state changes, possibly with no agent running
Credential rate-limitedContext nearing overflowIncoming external messagesSkill changesNew plugin version
For proactive behavior:Often more valuable: credential rate limits and imminent overflow are explicit opportunities to respond.
04 · TYPES

Event types (first version)

Selected moments that consumers want to respond to, rather than every framework action. Future types are additive (see 07).

TYPEWhenExisting source
Auser.prompt_submittedUser sends a messageDispatcher entry
Amodel.response_started / .completedModel starts / completes a responseAgentEventMessageStart/End
Atool.beforeTool about to execute (interceptable)AgentEventToolStart
Atool.afterTool completedAgentEventToolEnd
Afile.changedNewFile changedAttach to checkpoint_before_edit
Aturn.endedTurn completionAgentEventTurnEnd
Asubagent.started / .endedSubtask start/endJobRunner broadcast
Bcredential.cooldown / .exhausted / .rotatedCredential rate limit / pool exhaustion / rotationAuthStore._emit
Bcontext.compactedContext compactedcontext/engine.py
Bchannel.message_inboundIncoming external messageschannels/_broadcast.py
Bskills.changed / plugins.update_availableSkill changes / new plugin versionwebui watcher
05 · PLACEMENT

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.

# agent/event_bus.py · Follow the AuthStore pattern
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
01All threads in the process share one instance and call emit / subscribe directly. This is confirmed by investigation rather than assumed.
02Difference from the existing EventBus: replace channel-string subscriptions withevent-type subscriptions carrying a unified Event.
03Auth already implements events correctly(subscribe/_emit, 11 event types). This layer extends that pattern across the framework rather than inventing a new one.
06 · TWO MODES

Observation vs. interception

Observation

Default · Asynchronous

After 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 · Synchronous

Before 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
07 · PRINCIPLES

Two principles to retain

One

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.

Two

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.

Current wiring is defined by 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.