Expand description
Typestate handles that make protocol misuse a compile error.
Every streaming construct in AG-UI is bracketed: TEXT_MESSAGE_START …
TEXT_MESSAGE_END, TOOL_CALL_START … TOOL_CALL_END, STEP_STARTED …
STEP_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
RunContextmutably, so a second overlapping handle is a borrow-check error, not a runtime protocol violation; Dropemits the terminator, so forgettingend()— or returningErrthrough 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§
- Event
Receiver - The read end of a run’s event stream.
- Message
Handle - One open text message.
- Reasoning
Handle - One open reasoning block.
- Step
Guard - One open step.
- Subagent
Handle - One open subagent invocation.
- Tool
Call Handle - One open tool call.