Building AI Agents for DevOps Part 16: Streaming What the Agent Is Doing

Part 15 was about what the agent may do. This post is about something more visible: whether you can see it doing it. Until Orrery 0.6.0, asking for a health sweep meant watching a spinner. POST /chat...

Part 15 was about what the agent may do. This post is about something more visible: whether you can see it doing it.

Until Orrery 0.6.0, asking for a health sweep meant watching a spinner. POST /chat answered once the whole turn was over, and a sweep across five specialists takes about a minute. Slack showed nothing until the answer arrived. Nothing was broken, but a minute of silence from something allowed to touch production is hard to trust.

0.6.0 streams the answer as it is written, under a live trace of every specialist consulted and every tool it runs. The trace is the same in the web console, Slack and Google Chat. Here is the whole flow, against a real incident:

The Orrery console streaming an investigation: specialists and tool calls appear live, then the answer, then an approval card

The Runner Can't See Inside a Specialist

Streaming text is easy: ADK marks partial events, and a transport forwards them. Streaming progress is harder, because of how Orrery is built. The chat root calls each specialist as an AgentTool, and an AgentTool runs its agent in a separate Runner and keeps that Runner's events to itself. From the root's event stream, a whole Kafka investigation looks like one tool call that returns a minute later.

Plugins are the way through. AgentTool hands the parent's plugin list to the sub-Runner, so a plugin sees every tool call at every depth. Orrery already relied on this once: ToolLedgerPlugin is how the Google Chat bot knows what a timed-out turn had already changed. ProgressPlugin uses the same path to report progress.

ProgressPlugin sees tool calls inside AgentTool specialists and feeds a per-transport sink

Three choices keep it safe and cheap:


Closing the Tab Doesn't Stop the Agent

The new route is POST /chat/stream. It sends Server-Sent Events: the session id first, then progress and delta events, then the complete message, then done. EventSource can't be used, because it only sends GETs and can't set an Authorization header, so the console reads the stream from fetch() with a small parser.

The design decision that matters is where the turn runs. The obvious version runs the agent inside the response generator. When the client disconnects, Starlette cancels that generator, and the agent is cancelled with it, possibly in the middle of a tool call. For a read, nothing is lost. For a mutation, nobody knows whether the change went through, and a pending approval is left waiting for an answer that will never come.

So the turn runs in its own task, and the response only reads from a queue:

yield encode_event("session", {"session_id": session_id})

task = asyncio.create_task(run(), name=f"orrery-turn-{session_id}")
_running_turns.add(task)          # asyncio keeps only a weak reference
task.add_done_callback(_running_turns.discard)

while True:
    try:
        item = await asyncio.wait_for(queue.get(), timeout=keepalive_seconds)
    except TimeoutError:
        yield KEEPALIVE           # proxies cut idle connections
        continue
    if item is None:
        yield encode_event("done", {})
        return
    yield encode_event(*item)

The console's Stop button now means "stop waiting", not "stop the agent". The server finishes the turn and stores it, so reloading the conversation shows the answer. A few smaller details:


One Trace, Three Surfaces

The plugin emits raw start and end steps. Grouping them into "this specialist ran these tools" lives in a shared module, orrery_core.serving.progress, and each surface renders the result in its own way:

Surface

How it shows progress

The constraint it works around

Web console

A live trace above the answer that folds into one line when the turn ends

None: SSE delivers every step

Slack

One status message in the thread, edited as work happens, then reduced to a one-line receipt

chat.update allows about 50 calls a minute, so edits are throttled to one every 2 seconds

Google Chat

The "Investigating…" card lists each specialist and its tool calls

The card used to go quiet as soon as work moved inside a specialist

On Slack, everything is best-effort. A rate limit, a missing scope or a failed edit is logged and ignored, because progress must never cost an answer. A quick turn that uses no tools adds nothing to the thread. SLACK_PROGRESS_UPDATES=false turns the feature off.


The Console, Redesigned

Streaming changed what the console needed to be, so it was redesigned around it:


A Demo That Breaks Something Real

A streaming trace is only convincing if the incident behind it is real. make demo creates one on the local Docker stack, with two faults that tell one story:

Nothing is mocked. Ask "why is the orders consumer lagging?" and the Kafka, observability and Docker specialists each find their part of the story, and the agent connects them. Then it proposes a fix and waits for approval. make demo-reset removes everything.

The GIF above comes from make demo-gif. It runs the real server, plugins and gates against a scripted model, so the recording is reproducible and costs no tokens. The live demo uses a real model, and its wording changes from run to run.

Running the demo also found a bug. On a machine where Postgres wasn't running, make demo crashed at startup. Sessions and memory follow the platform's database policy: fail fast, or fall back to memory when ORRERY_DB_ALLOW_INMEMORY_FALLBACK allows it. The new agent-catalog store (see Part 17) connected on its own and ignored the policy. The fix was one line. The lesson is the one from Part 15: a policy that every new store has to remember is a convention, and the demo was the first test that exercised it.


The Takeaway

The engineering here is small: a plugin, a queue, a task. What mattered was deciding what the stream must not do. It must not stop the turn when the client leaves. It must not hide refused calls. And it must not become a second, unscreened channel for tool output.

The full list is in the v0.6.0 release notes. To try it, clone the repository and run make demo.

Read on Tirraflow