Skip to content

Notice when a Bot stops talking, instead of spinning forever - #19

Merged
davidmckayv merged 3 commits into
CopilotKit:mainfrom
jerelvelarde:feat/agent-stream-stall-watchdog
Aug 20, 2026
Merged

Notice when a Bot stops talking, instead of spinning forever#19
davidmckayv merged 3 commits into
CopilotKit:mainfrom
jerelvelarde:feat/agent-stream-stall-watchdog

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Notice when a Bot stops talking, instead of spinning forever

The problem

A Bot is any AG-UI endpoint, so it is infrastructure this deployment does not run: it gets redeployed mid-answer, its upstream times out, or it accepts a connection, writes a RUN_STARTED and never writes again. Nothing noticed. The stream stayed open, the channel stayed busy, the composer stayed locked, and the only way out was a reload or a restart.

Nor could anyone count it. One hung turn reads as a bad afternoon; the same Bot hanging twice a day for a month is a fact about an endpoint. The trail had no row for it: every event in audit.ts records something that happened, and this is the absence of anything happening.

The approach

Activity, not duration. A turn may run for an hour while events keep arriving; one whose stream has produced nothing at all for stallMs is wedged. The ceiling is on how long a person watches a spinner, not on how much work a Bot may do.

On the Bot's stream, not the reply to the browser. In Intelligence mode, OpenBot's only mode, POST /api/copilotkit/agent/:id/run does not stream: it answers fixed-length JSON in about a second, and events reach the browser over a WebSocket. The guard sits on the fetch an HttpAgent is built with; server/src/app.ts is untouched.

The clock is kept by the pump, not the relay. A TransformStream transforms only once the readable side is pulled, so timing there records when the consumer took a chunk, not when the Bot produced one — and that consumer is the Intelligence runner republishing over the network, which pauses. The relay is an identity transform, present only to give this process a writable end it can close from outside; the pump times each read off the Bot's body and stops the clock across the handover. A reader that stops for good is therefore never reported, which errs the safe way: believing a healthy Bot is broken ends somebody's turn. The relay may not buffer, await, or read the bytes.

What a stall does. Cancel the Bot's side, releasing the socket; queue one RUN_ERROR and close the stream; write the audit row last, since that store is a bare insert against the shared pool and a saturated pool is the condition a Bot is likeliest to hang in. A lost row beats a watchdog that fires and then parks. The sentence goes into every framing but the AG-UI protobuf media type, the rule @ag-ui/client 0.0.57 uses to pick its parser; being stricter would close a stream with nothing in it. Tool calls need no exemption: the run ends before the browser executes one.

Where the person reads it. Neither surface drew anything for a failed run, so both now draw the sentence from one source. The channel takes the shimmer's slot at the end of the transcript, and not as a message — no id, gone next turn — because the conversation goes back to the model. The direct Bot chat draws it under the page header, since the packaged chat virtualises its message list; the cost is that it is not beside the gap.

Configuration and the trail. AGENT_STALL_TIMEOUT_MS, read through config.ts, which refuses to boot on anything but a whole number of milliseconds; zero or unset switches the watch off, so no deployment acquires it unasked. .env.example ships 60000 because the deadline is in a race: Bots here serve on Bun with idleTimeout: 120, and Bun tears a wedged response down a second past that, handing the person a transport error and the trail no row. Each stall writes agent.stream_stalled with the Bot, thread, run, the length of the silence and how many chunks came first, which separates an endpoint that dies mid-answer from one that never answers. It joins the "Did not happen" filter and carries no actor.

What is not covered

  • Built-in Bots. A BuiltInAgent reaches its provider through the AI SDK, so there is no response body to wrap.
  • A Bot that never sends response headers. fetch has not resolved, so no body exists yet; that needs a connect-level timeout.
  • A stall that lands mid-frame. The injected event follows a half-flushed data: line and the parser rejects both. Forbidden to read bytes, the guard cannot see this, and the same frame fails identically with the watch off.
  • The protobuf framing. Closed cleanly rather than written into, since SSE bytes would corrupt a merely slow run; it ends in the client's own wording.
  • Extra rows per wedged turn. The runtime opens runs of its own against the same Bot for thread naming, each a separate stream.
  • Reporting lag. The sweep runs at a quarter of the timeout, capped at a second: 5604ms recorded against a 5000ms setting.
  • No retry or resume. The person is told and asked to ask again.
  • No rendering test for either line. The helpers that pick the wording are tested; the drawing was verified by reading and against a live server.

Merge notes

Single-hunk additions to server/src/audit.ts, the admin audit page, server/src/config.ts, server/src/index.ts and .env.example will conflict textually with policy-ask-a-person, repeated-action-detector and routines-and-webhook-triggers, and all resolve by keeping both entries. queue-and-steer-a-busy-bot needs more care: it edits the same three channel components and adds its own props and rendering in the same hunk as the shimmer. Both sets of props must survive, and so must the rule added here, that the stopped line replaces the shimmer rather than sitting beside it. Separately, mountCopilotRuntime gained a required stallGuard before its defaulted basePath.

Verification

format and format:check over 295 files, no fixes applied; lint exit 0 with 24 warnings, all pre-existing and elsewhere; typecheck exit 0 for app, server and worker; build exit 0. bun run test gives 635 pass, 5 skip, 0 fail across 73 files, 41 more than main's 594; the skips are the pre-existing smoke tests gated on OPENBOT_SMOKE.

server/tests/turn-watchdog.test.ts pins the activity rule on an injected clock, including that a paused handover is not charged to the Bot. server/tests/stall-guard.test.ts runs real streams through the wrapper: the sentence and row a silent Bot produces, bytes unchanged when the reader waits five times the timeout before its first read, and recovery completing while the audit insert never settles. app/tests/stopped-turn.test.ts and app/tests/audit-silence.test.ts cover the wording of the two lines a person reads. Both defect fixes carry a test confirmed failing beforehand.

The whole path was also driven against a live server with a silent stub Bot, which produced the RUN_ERROR in the thread's events, a cancelled upstream stream, and the audit row.

A Bot is any AG-UI endpoint, so it is infrastructure this deployment does not
run: it gets redeployed mid-answer, its own upstream times out, and it will
happily hold a connection open and write nothing more. Nothing here noticed. The
channel stayed busy, the composer stayed locked, and the only way out was to
reload the page or restart the server, which is a poor thing to ask of somebody
who is looking at a Bot that appears to be thinking and never was.

The watch is on activity, not on duration. A turn may legitimately run for an
hour while events keep arriving; a turn whose stream has produced nothing at all
for the configured timeout is wedged. A duration limit would cap how much work a
Bot is allowed to do, which nobody asked for. This caps how long a person is
asked to watch a spinner, which is the actual complaint.

It sits on the Bot's own response body rather than on the reply to the browser,
because in Intelligence mode that reply is a JSON envelope with a fixed length
and the AG-UI events reach the browser over a WebSocket to the gateway. The
Bot's response is the stream that stalls and the only place a stall can be seen.
The wrapper is a pass-through transform that counts chunks and does nothing
else: it must not buffer, must not delay a chunk and must not read the bytes,
because a watchdog that can misread a working run into a broken one is worse
than the failure it was added for.

On a stall it writes one RUN_ERROR into the same stream and closes it. Both
surfaces already understand that event, so nobody downstream had to learn a new
one; the sentence it carries names the Bot and says what happened. The channel
now draws that sentence at the end of the transcript rather than above the
composer, which is where the missing answer was going to be and where the person
is already looking.

No exemption exists for a frontend tool call, and none is needed. The run ends
before the browser executes one: the Bot emits its tool calls and RUN_FINISHED,
its stream closes, the browser runs the tool and a second run carries the result
back. A browser tool that takes ten minutes holds no stream open.

AGENT_STALL_TIMEOUT_MS configures it, and zero or an unset variable leaves every
stream alone. A turn that is ended is a turn somebody loses, so an existing
deployment does not acquire that behaviour without asking for it; .env.example
ships two minutes, which is already what a Bot in this repository treats as the
outer bound of a quiet connection. Each stall writes an agent.stream_stalled
row, because one hung turn reads as a bad afternoon and the same Bot hanging
twice a day for a month is a fact about an endpoint that only becomes visible
when somebody can count it.
The clock was kept in the wrong place. `watchdog.record` was called from the relay's
transform callback, and a TransformStream runs its transform only once the readable
side is being pulled, so what was being timed was the moment the CONSUMER took a
chunk rather than the moment the Bot produced one. A reader that paused for longer
than the timeout therefore looked exactly like a Bot that had gone silent: the run
was ended, the audit row said the endpoint had sent nothing, and the person was told
their Bot had stopped responding while it was streaming the whole time. In this
deployment that reader is the Intelligence runner publishing every event on to the
gateway over the network, which is precisely the sort of thing that pauses. The pump
now times the resolution of each read from the Bot, and stops the clock across the
handover to the relay, so the only quiet ever counted is quiet on the wire.

The recovery no longer waits on the database. The audit row was written before the
stream was touched, on the argument that a record must not depend on a promise a
broken stream owes us; but the writes it was protecting are queued and unawaited
anyway, while the insert it moved in front of them is a bare statement against the
pool every other write shares, with no deadline of its own. A saturated pool or an
unreachable Postgres is exactly the condition a Bot is most likely to hang in, and
in that condition the watchdog fired and then parked, leaving the spinner and the
locked composer it exists to end. The socket is now released, the sentence queued
and the stream closed first, and the row written after.

The direct Bot chat says something now. The channel drew the sentence at the end of
its transcript; the other surface drew nothing at all, because the banner it was
assumed to have belongs to a provider this app does not mount and is suppressed even
there unless the dev console is on. It now watches the same runs the chat starts and
draws a line under the page header. Not where the missing answer was going to be,
which is where the channel puts it, but the packaged chat owns and virtualises its
message list and reaching into it means taking on its scrolling. Both surfaces fall
back to the same sentence from the same place, so a person who uses both is not told
two different things about the same silence.

A stream is now judged by the rule the client actually applies. The guard would only
write into `text/event-stream`, which is stricter than @ag-ui/client, and being
stricter was not the safe direction it was argued to be: a Bot serving events under
any other content type had its stream closed with nothing in it, so the run ended
and nobody was told anything on either surface. The client treats everything except
the protobuf media type as server-sent events, and so does this now.

The shipped timeout drops to a minute, because two minutes was in a race it could
lose. Every Bot in this repository serves on Bun with `idleTimeout: 120`, and Bun
tears a wedged streaming response down about a second past that; a watchdog set to
the same two minutes lands within a second of the socket dying, and when it lost the
person got a transport error instead of the designed sentence and the trail got no
row. Half the Bot's own idle timeout is far enough clear to be deterministic, and is
still far longer than any real silence inside a run.

Three smaller things on the trail and around it. A stalled turn takes the same colour
as an action that did not happen, rather than the muted one that reads as "Allowed",
and it joins the "Did not happen" filter. The two numbers the row exists to carry are
drawn: how long the stream was silent, and whether the Bot had managed to say
anything first, which is the difference between an endpoint that dies mid-answer and
one that never begins. And the test asserting an unwatched Bot's fetch was left alone
was asserting nothing, since @ag-ui/client fills that field in either way; it now
tells the two apart with a sentinel.
davidmckayv
davidmckayv previously approved these changes Aug 19, 2026

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the code and run locally. Full suite passes on a clean database (bun run test:ci), format, lint and typecheck clean.

No security concerns: this touches no auth boundary and nothing it adds can be reached without going through the gateway.

@jerelvelarde
jerelvelarde marked this pull request as ready for review August 19, 2026 23:48
Main grew a queue in the channel composer and a repeat count on the boundary
while this branch was open, and both of them landed in the files the watchdog
threads its `stopped` prop through.

The three channel components now carry both. `ChatTranscript` draws the red line
where `Thinking` was and the parked messages under it; `ConversationView` takes
`stopped` alongside `queueWhileBusy` and `stoppable` and passes each to the half
that wants it; `ChannelChat` hands down `runError` as well as the two counters
main added.

A turn the watchdog ends is a turn that ended, so the queue drains on it exactly
as it does on Stop. Nothing was needed to make that true: the watchdog writes one
RUN_ERROR into the stream and closes it, so `runAgent` settles, both counters
come back down in their `finally` blocks, and the drain sees the same falling
edge it sees for every other way a turn can finish. The one path with a branch of
its own is a disabled conversation, which is main's and is untouched.

The audit page keeps both new filters and both new labels, and `config.test.ts`
keeps both sides' cases.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after the rebase. 686 pass / 0 fail locally.

The conflict with #16 that I stopped short of resolving is gone, and the merge kept both sides: the stopped-turn notice and the queued-message rendering coexist rather than one displacing the other.

Adds no migration, so it is unaffected by the 0001 numbering across #15, #18 and #20.

@davidmckayv
davidmckayv merged commit 6b9e4cc into CopilotKit:main Aug 20, 2026
3 checks passed
davidmckayv added a commit to jerelvelarde/openbot that referenced this pull request Aug 20, 2026
Both branches added to the same audit row renderer. CopilotKit#19 colours a stalled
turn as not-carried-out and prints how long the silence was; this branch
prints the rule a question was asked under. Neither displaces the other.
davidmckayv added a commit that referenced this pull request Aug 20, 2026
The approval registry from #15 kept its pending questions in a Map in the process, and the
repetition detector from #17 kept its counts the same way. Both are correct on a laptop and both
stop being correct the moment a deployment runs a second server process, which is the deployment
every part of this is aimed at: several processes behind a load balancer, serving a company.

The failure mode is the reason to take them back rather than leave them and fix them later. Neither
one breaks loudly. A question raised on one process and answered on another is reported as no longer
open, which reads exactly like an expiry. Counts split across processes mean a rule written as
`repeat.count >= 10` never fires, which reads exactly like a Bot behaving itself. A boundary that
silently stops enforcing is worse than one that was never advertised, because the deployment is
relying on it.

The ask lists, the approval surface, the repetition context and the two audit event kinds go with
them. What stays is everything that was already right: the stall watchdog, which tracks streams in
the process that holds them and belongs there, and the client-side work from #16 and #19.

The rule is now on the pull request template, stated before the work rather than at review. Our own
gateway snapshot cache has the same problem and is next.
davidmckayv added a commit that referenced this pull request Aug 21, 2026
Audited every markdown file against everything that landed today, including the
work that was not mine.

`docs/coworkers.md` still told people to point `MANAGED_AGENT_AG_UI_URL` at
`4200`. #33 made `agent-langgraph` on `4201` the default precisely because the
proof-of-concept hand-writes the protocol and leaves the tool loop to whatever is
watching, so following that page produced the shape the change moved away from.

Three environment variables the server reads were in `.env.example` and nowhere
in the configuration reference: `AGENT_STALL_TIMEOUT_MS` from #19, which is the
only thing that notices a Bot's stream going silent; `AGENT_TOOL_TOKEN` from #34,
without which no framework Bot may call a granted tool back; and `APP_DIST_DIR`,
which the container sets so one process serves both halves.

Both documentation indexes had fallen behind their own directory and listed
neither `deployment.md` nor `releasing.md`.

`docs/development.md` gains the migration workflow the checks in #64 now enforce:
never hand-edit a generated migration, write a data step with `--custom`, and
what to do when `drizzle-kit migrate` hangs and exits non-zero with nothing
printed, which is the journal naming a file a rebase renamed. `drizzle-kit check`
calls that state fine, because it compares schemas rather than asking whether the
journal and the directory agree.

The README keeps its shape: what this is, how to run it, how to deploy it, and
where to read the rest.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants