Skip to main content

Module emit

Module emit 

Source
Expand description

Typestate handles that make protocol misuse a compile error.

Every streaming construct in AG-UI is bracketed: TEXT_MESSAGE_STARTTEXT_MESSAGE_END, TOOL_CALL_STARTTOOL_CALL_END, STEP_STARTEDSTEP_FINISHED. Handing an agent three raw emit calls per construct means trusting it to close what it opened, in order, on every path including the early return. This module hands out RAII handles instead:

  • creating a handle emits the opening event;
  • the handle borrows the RunContext mutably, so a second overlapping handle is a borrow-check error, not a runtime protocol violation;
  • Drop emits the terminator, so forgetting end() — or returning Err through a ? halfway through a message — still produces a well-formed stream.
use ag_ui::server::RunContext;

fn interleave(ctx: &mut RunContext<()>) {
    let mut first = ctx.assistant_message().unwrap();
    // error[E0499]: cannot borrow `*ctx` as mutable more than once at a time
    let mut second = ctx.assistant_message().unwrap();
    first.delta("a").unwrap();
    second.delta("b").unwrap();
}

§Why the emit path is synchronous

Drop cannot be async, so a handle cannot await while emitting its terminator. msg.delta(text)? therefore does not take .await: emitters push into an unbounded channel and the transport drains it. An earlier draft copied await-ing emitters from the TypeScript and .NET SDKs; it cannot coexist with the Drop guarantee.

§The escape hatch

StepGuard dereferences to the run context — a step is a scope, and everything else nests inside it. The three streaming handles deliberately do not, because that is exactly what would let a second message open inside the first. They expose emit instead, for the unordered events (state, activity, custom) that may legally interleave with a message.

§What an open handle can still reach

A handle borrows two fields of the run context — the event sink and the state — rather than the context itself. So the state is reachable through the handle (state, state_mut, publish_state) and a tool call can do its work between its arguments and its result: STATE_* is unordered, so a publish inside the brackets is a legal stream.

Widening reach, not weakening the rule. The context stays exclusively borrowed for as long as the handle lives, so a second block is still a borrow-check error — including from inside an open call:

use ag_ui::server::RunContext;

fn narrate(ctx: &mut RunContext<()>) {
    let mut call = ctx.tool_call("search").unwrap();
    // error[E0499]: cannot borrow `*ctx` as mutable more than once at a time
    let mut message = ctx.assistant_message().unwrap();
    call.args("{}").unwrap();
}

§What has no handle, and why that is the answer

Two things an agent may legitimately put on the wire are RunContext::emit territory, and the escape hatch is the supported path for both rather than a gap waiting for an API.

The *_CHUNK family is unbracketed by definition: a chunk carries its own id and needs no start and no end, which is the point — it exists for provider adapters that cannot know a message ended until the next one begins. There is nothing for an RAII handle to close, and wrapping one around a self-contained event would only add a way to get it wrong.

Interleaved parallel tool calls are the other. Two open ToolCallHandles at once is a borrow-check error by design, so a provider streaming args(a) args(b) args(a) end(a) end(b) cannot be mirrored handle-for-call. Either accumulate each call and emit it whole once its arguments are complete — what e2e/src/llm.rs does, and the only mapping that cannot splice two calls’ arguments into each other — or emit the interleaving yourself. The verifier keys everything by id, so it accepts the interleaved stream; what it will not let you do is close a call you never opened.

§Subagents

SubagentHandle is a scope in the sense StepGuard is: it dereferences to the run context, and everything opened through it — messages, tool calls, reasoning, steps, nested subagents — comes out carrying its subagentRunId. The attribution lives in the event sink rather than in the handles, which is why a MessageHandle opened inside a subagent needs no idea that it was: the sink tags every attributable event that arrives untagged while a scope is open, and leaves an event the agent tagged explicitly alone.

That last clause is the concurrent case. Subagents that stream at once are the parallel-tool-call situation again: two open handles is a borrow-check error by design, so build each subagent’s events with Event::with_subagent_run_id and emit them interleaved, bracketed by Event::subagent_started and Event::subagent_finished_success. The verifier keys every entity by id and remembers who opened it, so the interleaving is accepted; what it will not let you do is continue one subagent’s message under another’s tag. Attribute every chunk when several subagents stream at once — a chunk that names neither a message nor a subagent can only be resolved when one stream is open.

Structs§

EventReceiver
The read end of a run’s event stream.
MessageHandle
One open text message.
ReasoningHandle
One open reasoning block.
StepGuard
One open step.
SubagentHandle
One open subagent invocation.
ToolCallHandle
One open tool call.