diff --git a/.env.example b/.env.example index 229792f..f25c4b7 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,29 @@ INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai INTELLIGENCE_API_KEY= COPILOTKIT_LICENSE_TOKEN= +# How long a Bot's stream may say nothing before this deployment gives up on the turn, in +# milliseconds. A Bot is any AG-UI endpoint, which means it will be redeployed mid-answer, its own +# upstream will time out, and it will sometimes accept a connection and then write nothing at all. +# Without this the channel stays busy, the composer stays locked, and the only way out is a reload. +# +# Silence, not duration. A turn may legitimately run for an hour while events keep arriving; what is +# measured here is the gap between them. Nothing about how long a Bot is allowed to work changes. +# +# A minute, because this deadline is in a race it has to win. Every Bot in this repository serves on +# Bun with `idleTimeout: 120`, and Bun tears a wedged streaming response down at roughly a second +# past that. A watchdog set to the same two minutes lands within a second of the socket dying, and +# whichever gets there first decides what the person sees: this deployment's sentence naming the Bot +# and saying the turn was ended, or "The socket connection was closed unexpectedly" and no audit row +# at all. Half the Bot's own idle timeout is far enough clear that the answer is always the first. +# +# A minute is still far longer than any real silence inside a run. The longest legitimate one is the +# wait for a model's first token, which is seconds. Browser tool calls do not need allowing for: +# they run between turns, not during one, because the run ends before the browser executes the tool +# and a second run carries the result back. +# +# 0, or leaving this unset, switches the watchdog off. Nothing is watched and no turn is ever ended. +AGENT_STALL_TIMEOUT_MS=60000 + # Model key. Required by the proof-of-concept Bot, which speaks OpenAI's API directly, and by the # framework Bot unless you point it at another provider below. OPENAI_API_KEY= diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx index d16b835..dabb8ba 100644 --- a/app/src/components/channels/channel-chat.tsx +++ b/app/src/components/channels/channel-chat.tsx @@ -19,6 +19,7 @@ import type { AgentChannel } from "@/lib/channels/queries"; import { useActiveBot } from "@/lib/copilot/active-bot"; import { ConversationProvider } from "@/lib/copilot/conversation"; import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history"; +import { stoppedReason } from "@/lib/copilot/stopped-turn"; import { useSkillCommands } from "@/lib/plugins/skill-commands"; /** @@ -271,14 +272,10 @@ export function ChannelChat({ setRunError(message); }; const subscription = agent.subscribe?.({ - onRunErrorEvent: ({ event }) => - fail(event?.message ?? "The Bot stopped without saying why."), - onRunFailed: ({ error }) => - fail( - error instanceof Error - ? error.message - : "The Bot stopped without saying why.", - ), + // 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. + onRunErrorEvent: ({ event }) => fail(stoppedReason(event?.message)), + onRunFailed: ({ error }) => fail(stoppedReason(error)), onRunFinishedEvent: () => { const wasOurs = awaitingReply.current; awaitingReply.current = false; @@ -339,23 +336,12 @@ export function ChannelChat({ disabled={!channel.active} messages={transcriptMessages(agent.messages, seed)} notice={ - <> - {runError ? ( -
- {runError} -
- ) : null} - {channel.active ? null : ( -- This coworker has been deleted. The conversation stays readable, - but it can no longer reply. -
- )} - > + channel.active ? null : ( ++ This coworker has been deleted. The conversation stays readable, + but it can no longer reply. +
+ ) } onSubmit={async (draft) => { // `draft.agentId` carries the @mentioned coworker, but nothing routes on it yet: this @@ -405,6 +391,17 @@ export function ChannelChat({ * this is the one place the narrower fact is the honest one to draw a button from. */ stoppable={agent.isRunning || runsInFlight > 0} + /* + * At the END OF THE TRANSCRIPT rather than above the composer, which is where this used to + * be. A turn that ends without an answer leaves a gap exactly where the reply was going to + * appear, and the person is already looking at it; an explanation in the composer area is a + * different part of the screen from the thing it explains. + * + * `runError` carries whatever ended the turn, in that thing's own words. A Bot that stopped + * streaming says so, because the deployment's stall watchdog writes that sentence into the + * run before closing it; see server/src/channels/stall-guard.ts. + */ + stopped={runError ?? undefined} /> ); diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index 89406dc..42b3ebf 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -38,6 +38,14 @@ type ChatTranscriptProps = { queued?: readonly QueuedMessage[]; /** Take one back before it runs. Without it a queued line is shown but cannot be undone. */ onRemoveQueued?: (id: string) => void; + /** + * Why the last turn ended without an answer, if it did. + * + * A sentence rather than a flag, because the reasons are not interchangeable: a Bot that refused, + * a Bot whose endpoint is down and a Bot that simply stopped talking are three different things to + * be told, and only the thing that ended the turn knows which one happened. + */ + stopped?: string; }; /** One shared empty array, so a screen without a queue does not hand down a new one per render. */ @@ -91,6 +99,31 @@ function Thinking() { ); } +/** + * The turn ended and no answer came. + * + * In the same slot as `Thinking`, and for the same reason it is there: the person is looking at the + * bottom of the transcript, immediately under their own message, because that is where the answer + * was going to appear. Saying so above the composer put the explanation in a different part of the + * screen from the gap it explains, and left the last thing in the conversation looking unfinished. + * + * NOT A MESSAGE, deliberately. It has no id, is never anchored, and is gone the moment the next turn + * starts. Making it a transcript row would put a sentence into the conversation that nobody said, + * and the conversation is sent back to the model on the next turn, so the Bot would then read its + * own obituary as something it had written. + */ +function Stopped({ reason }: { reason: string }) { + return ( ++ {reason} +
+ ); +} + /** * Something the person said while the Bot was working, waiting its turn. * @@ -455,6 +488,7 @@ export function ChatTranscript({ messages, onRemoveQueued, queued = EMPTY_QUEUE, + stopped, }: ChatTranscriptProps) { /* * NOT MEMOISED, AND THAT IS DELIBERATE. `useMemo` keyed on `messages` looks obviously right and @@ -545,11 +579,18 @@ export function ChatTranscript({ ), )} {/* - * Outside the item list, so it is not a message. It has no id, is never anchored, and - * disappears the moment the answer starts — giving it a `MessageScrollerItem` would ask + * Outside the item list, so neither of these is a message. Each has no id, is never + * anchored, and is gone by the next turn — giving one a `MessageScrollerItem` would ask * the scroller to measure and anchor something that exists for a second and a half. + * + * One or the other, never both: a turn that ended has stopped being in flight, and a + * shimmering "Thinking" under a line saying the Bot stopped would contradict it. */} - {waitingOnFirstToken ?+ {stopped} +
+ ) : null}