Events, Channel & Trajectory

This page specifies how the target and the optimizer communicate. They never call each other. They run as two independent concurrent tasks and exchange typed events over an EventChannel, while everything that happens is recorded onto a Trajectory. This is the substrate every other component sits on.

Three ideas, in order:

  1. Events and responses are the vocabulary: typed messages with a fixed set of valid replies.
  2. The channel carries the two-way ones; the trajectory records them all.
  3. Middleware is the thin layer where the Controller enforces scope and does the recording.

Events and responses

An event is a typed message about something that happened or is about to happen. An event response is the reply to one. Both are frozen dataclasses defined with kw_only=True (keyword-only construction), so subclasses can add required fields without fighting Python’s field-ordering rules.

Event (base)

Every event carries three fields plus one class-level declaration:

EventResponse (base)

A response carries exactly one field:

Every response references its event, so the two sides can correlate a reply with its request even when several are in flight at once.

The concrete events

There are two families: controllable events (two-way, routed to the optimizer for an answer) and observable / lifecycle events (recorded, and in the lifecycle case answered but carrying no injectable surface).

Event Direction Fired when Valid responses (response_types)
ControllablePreCallEvent two-way the target reaches an injection point and needs a value ControllableInjection, ControllableNoInjection
ControllablePostCallEvent two-way the injected value has been used, so the optimizer can observe the effect ControllableInjection, ControllableNoInjection
ObservableEvent one-way the target records a fact about the run (a model request, a response, a tool call) not routed; recorded directly
RunStartEvent two-way just before a run begins any EventResponse
RunEndEvent two-way after the run has been evaluated RunEndResponse

Controllable events carry the Controllable they concern plus the current request string (ControllablePostCallEvent adds the answer that was produced). Their security_domain is auto-derived from controllable.security_domain in __post_init__ if not set explicitly, so a target never has to tag them by hand.

ObservableEvent carries an Observable and a content: Any payload. It is the one event the target emits fire-and-forget (through the emit callback, not the channel); it is recorded straight onto the trajectory and never routed to the optimizer for a reply. Its security_domain auto-derives from the observable.

RunStartEvent carries the trajectory for the new run (a FilteredTrajectory when the recipient is the optimizer).

RunEndEvent carries evaluation: EvaluationResult | None (default None). When the Controller was built with include_feedback=True (the default), this is the scope-filtered evaluation of the run; with include_feedback=False it is None. RunEndEvent is the one lifecycle event that is persisted to the trajectory, because it carries feedback the optimizer may read from past runs; its security_domain is set by the Controller from the active scope so it passes trajectory validation.

The concrete responses

A single injection type serves both the pre-call and post-call events; the inherited event field tells them apart.

One run, in sequence

Here is a single run as it crosses the channel. Controller steps are the middleware layer described below.

trajectory
Target
Controller
+ middleware
Optimizer
RunStartEvent not recorded
EventResponse
emit(ObservableEvent)
send_event(ControllablePreCallEvent)
ControllablePreCallEvent
ControllableInjection
resume target with value
RunEndEvent(evaluation)
RunEndResponse(done)
filter + record
task.evaluate() EvaluationResult
close trajectory · reset target · loop or stop
recorded on the trajectory reply (event response) Observable events, controllable events & their responses, and RunEndEvent are recorded; RunStartEvent is not.

RunStartEvent is not recorded (it always sits at a fixed position and carries the trajectory itself). Every other item, observable events, controllable events, their responses, and RunEndEvent, lands on the trajectory in order.

EventChannel and EventEnvelope

The channel is a thread-safe, bidirectional queue. The Controller is on the send side (it forwards the target’s two-way events); the optimizer is on the receive side.

Internally the channel is an asyncio.Queue, so all queue access happens on the event-loop thread. Thread safety for the cross-thread operations (respond, reject, close, set_error) comes from bridging onto that thread with call_soon_threadsafe. The interface is deliberately narrow so that a future process-safe implementation (sockets, multiprocessing) could satisfy the same contract.

EventChannel

EventEnvelope

An envelope pairs one event (public) with the machinery to answer it. The receiver calls exactly one of:

set_error (on the channel) and reject (on the envelope) are the two halves of the framework’s no-deadlock guarantee: if the optimizer’s on_event raises, the envelope is rejected so the target’s await send_event sees the exception, and the Controller then poisons the channel so no other in-flight send hangs.

Trajectory

A trajectory is the ordered record of one run: a stream of Event and EventResponse objects, which together are the type alias TrajectoryItem = Event | EventResponse. It is the single source of truth for what happened during a run. There is no separate event log.

get_domain(item)

Every recorded item must be attributable to a security domain. get_domain is the public function that extracts it:

Trajectory (class, thread-safe)

The producer side pushes items with emit; consumers read without blocking.

All public methods take a threading.Lock, so a target may emit from a worker thread.

The Trajectory handed to the target’s run() is not the object itself: the target only receives the emit callback (typed EventHandler), so it can record but cannot read or close the trajectory.

FilteredTrajectory (class, thread-safe)

The optimizer never sees the full trajectory. It receives a FilteredTrajectory: a read-only view that contains only the items whose security domain is within the optimizer’s scope. It exposes the same non-blocking readers, snapshot() and drain(), with its own cursor, and has no emit or close.

You create one by passing filtered_scope to the Trajectory constructor and reaching it via trajectory.filtered.

Trajectory full record
ObservableEventsystem_prompt
ControllablePreCallEventuser
ControllableInjectionuser
ObservableEventinternal
ControllablePreCallEventtools
RunEndEvent‹scope›
pushed at emit time
in-scope only
FilteredTrajectory the view
ObservableEventsystem_prompt
ControllablePreCallEventuser
ControllableInjectionuser
RunEndEvent‹scope›
no back-reference to the parent (__slots__)
Filtering happens once, at emit time: the parent pushes each in-scope item into the view. The push is one-way, so an optimizer handed a FilteredTrajectory has no path to the unfiltered data. A deliberate security boundary.

Push-based, one-way isolation. Filtering happens once, at emit time: when an in-scope item is emitted, the parent trajectory pushes it into the filtered view. The FilteredTrajectory holds no reference to the parent, not as an attribute, not in a closure. __slots__ prevents attributes from being attached later. The parent points to the child, never the reverse. This is a deliberate security boundary: an optimizer that is handed a FilteredTrajectory has no mechanism to reach the unfiltered data.

ReadableTrajectory = Trajectory | FilteredTrajectory is the type alias used wherever either a full or a filtered trajectory is accepted (for example the trajectory field on RunStartEvent and the optimizer’s current_trajectory).

Middleware: where scope and recording live

The Controller does not hard-code filtering and recording into its loop. It wraps the channel’s send with two small middleware functions. A middleware is a function that takes an event handler and returns a wrapped one:

Middleware = Callable[[EventResponseHandler], EventResponseHandler]

compose(*middlewares) chains them left-to-right, so the first listed is the outermost. It is pure function composition: no extra tasks, no extra channels, no per-call overhead beyond the function calls themselves. The Controller builds the send_event it hands to the target like this:

send_event = compose(
    trajectory_recorder(trajectory),          # outermost: records everything
    security_domain_filter(write_scope),      # inner: declines non-injectable events
)(channel.send)

The two built-ins:

The order matters. Because trajectory_recorder is outermost, an event declined by the filter is still recorded, which is exactly what makes a read-only surface work: the Controller passes the write scope to the filter (so a read-only controllable’s event is declined), but the recorder still writes it, and it is within the wider visibility scope, so it remains visible through the optimizer’s filtered trajectory. The read-versus-write distinction is specified in Security Domains.

Custom middleware is not a public extension point today. The Controller composes this fixed two-stage stack internally. If you need extra behavior (rate limiting, tracing), the supported places are inside your target’s run() or your optimizer’s on_event().

Callback type aliases

The target’s run(emit, send_event) receives its two channels not as objects but as two callbacks, so it is decoupled from the channel entirely:

The Controller supplies both, wrapping the channel and the middleware; the target neither knows nor cares what is on the other end.

Concurrency model

There is one event loop on one thread (provided by the caller). On it, the target’s run() and the optimizer’s run() are two separate asyncio.Tasks. Cooperative scheduling interleaves them: each yields at its await points and the loop advances the other.

Target internal parallelism. A target may itself fan out into concurrent branches (via asyncio.gather or asyncio.create_task), each calling send_event independently. Each call creates its own future and suspends only that branch; other branches continue. This models an AI system that, for example, issues several tool calls at once.

async def run(self, emit, send_event):
    async def branch_a():
        resp = await send_event(event_a)   # suspends only this branch
        ...
    async def branch_b():
        resp = await send_event(event_b)   # suspends only this branch
        ...
    await asyncio.gather(branch_a(), branch_b())

Bridging blocking work. A target backed by threads (Docker, a subprocess) runs its blocking work off the loop and bridges each event back onto it with asyncio.run_coroutine_threadsafe:

async def run(self, emit, send_event):
    loop = asyncio.get_running_loop()
    def blocking_work():
        event = parse_event_from_subprocess(proc)
        future = asyncio.run_coroutine_threadsafe(send_event(event), loop)
        response = future.result()   # blocks this worker thread until the reply
        ...
    await loop.run_in_executor(None, blocking_work)

Optimizer consumption is the optimizer’s choice. The default run() consumes events one at a time. An optimizer may override run() to spawn a task per event (parallel) or to run background work alongside event handling (continuous). The Optimizer page details this.

Because every shared object (trajectory, channel, envelope, LLM client) is thread-safe, these patterns are safe to combine.