Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
47 changes: 22 additions & 25 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -339,23 +336,12 @@ export function ChannelChat({
disabled={!channel.active}
messages={transcriptMessages(agent.messages, seed)}
notice={
<>
{runError ? (
<p
className="pb-2 text-sm text-destructive"
data-testid="channel-run-error"
role="alert"
>
{runError}
</p>
) : null}
{channel.active ? null : (
<p className="pb-2 text-sm text-muted-foreground" role="status">
This coworker has been deleted. The conversation stays readable,
but it can no longer reply.
</p>
)}
</>
channel.active ? null : (
<p className="pb-2 text-sm text-muted-foreground" role="status">
This coworker has been deleted. The conversation stays readable,
but it can no longer reply.
</p>
)
}
onSubmit={async (draft) => {
// `draft.agentId` carries the @mentioned coworker, but nothing routes on it yet: this
Expand Down Expand Up @@ -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}
/>
</ConversationProvider>
);
Expand Down
47 changes: 44 additions & 3 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 (
<p
className="text-destructive text-sm"
data-testid="transcript-stopped"
role="alert"
>
{reason}
</p>
);
}

/**
* Something the person said while the Bot was working, waiting its turn.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ? <Thinking /> : null}
{stopped ? (
<Stopped reason={stopped} />
) : waitingOnFirstToken ? (
<Thinking />
) : null}
{/*
* Below the thinking line, and outside the item list for the same reason it is: these
* are not yet turns. They have ids of their own, but they are this tab's ids and not the
Expand Down
4 changes: 4 additions & 0 deletions app/src/components/channels/conversation-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function ConversationView({
commands,
disabled = false,
pending = false,
stopped,
stoppable,
queueWhileBusy = false,
onSubmit,
Expand All @@ -50,6 +51,8 @@ export function ConversationView({
* drains on this falling.
*/
pending?: boolean;
/** Why the last turn ended without an answer. Drawn at the end of the transcript, not here. */
stopped?: string;
/**
* There is a run for Stop to abort, which is a narrower fact than `pending` and is the honest one
* to draw a Stop button from. Defaults to `pending` for a caller with no gap between the two.
Expand Down Expand Up @@ -209,6 +212,7 @@ export function ConversationView({
apply({ id, type: "remove" });
}}
queued={queued}
{...(stopped ? { stopped } : {})}
/>
</div>
<div className="max-w-2xl mx-auto w-full px-0 pb-4 shrink-0">
Expand Down
29 changes: 29 additions & 0 deletions app/src/lib/audit/silence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* How a stalled turn reads on the audit page.
*
* The row for a Bot that stopped talking carries two numbers nothing else in the trail carries: how
* long its stream had been quiet when the deployment gave up on it, and how much it had managed to
* say first. They are the whole reason that row exists. An endpoint that dies halfway through an
* answer and one that accepts a connection and never writes are different faults with different
* fixes, and on the page they are the same line unless these two are drawn.
*
* Chunks, not events, because that is what was counted: one chunk can carry several AG-UI events and
* the boundaries are the network's. Saying "events" here would be a number that looks precise and is
* not. What a reader needs from it is whether it is zero, and that it says plainly.
*
* Returns null rather than a placeholder when the payload does not carry both. An older row written
* before this was recorded should show nothing, not "0 chunks", which would be a claim about a Bot
* that nobody ever measured.
*/
export function silenceOf(payload: Record<string, unknown>): string | null {
const silentForMs = payload.silentForMs;
const chunks = payload.chunks;
if (typeof silentForMs !== "number" || typeof chunks !== "number") {
return null;
}

const seconds = Math.max(1, Math.round(silentForMs / 1000));
const quiet = `Silent for ${seconds}s`;
if (chunks === 0) return `${quiet}, having said nothing at all`;
return `${quiet}, after ${chunks} ${chunks === 1 ? "chunk" : "chunks"}`;
}
60 changes: 60 additions & 0 deletions app/src/lib/copilot/stopped-turn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useAgent } from "@copilotkit/react-core/v2";
import { useEffect, useState } from "react";

/**
* Why the last turn ended without an answer, for a surface that has to say so itself.
*
* A run can end three ways. It finishes, which needs no explanation. It fails in the browser, which
* arrives as an error. Or the Bot's own stream stops producing anything and this deployment ends the
* turn for it, which arrives as a RUN_ERROR carrying the sentence the server wrote (see
* server/src/channels/stall-guard.ts). The last two both leave the same hole on screen: the composer
* unlocks, the spinner disappears, and nothing says what happened.
*
* The reason is kept as 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 it was.
*/

/**
* The sentence to show, in the words of whatever ended the turn.
*
* Falls back only when there is genuinely nothing to pass on. Saying "the Bot stopped without saying
* why" is honest about that; inventing a cause would not be, and this is the one moment a person has
* no other way to find out what went wrong.
*/
export function stoppedReason(reported: unknown): string {
const said =
reported instanceof Error
? reported.message
: typeof reported === "string"
? reported
: "";
return said.trim() || "The Bot stopped without saying why.";
}

/**
* Watch one Bot's runs and hold on to the reason the last one ended, if it ended badly.
*
* Bound by agent id rather than handed an agent, so a caller that only renders the packaged chat
* does not have to reach for one: `useAgent` returns the same shared instance the chat itself binds
* to, so this watches exactly the runs that chat starts.
*
* Cleared when the next run begins rather than on a timer. A sentence about a turn that is over
* should stay until there is something newer to look at, and the person deciding when that is is the
* one who sends the next message.
*/
export function useStoppedTurn(agentId: string): string | null {
const { agent } = useAgent({ agentId });
const [stopped, setStopped] = useState<string | null>(null);

useEffect(() => {
const subscription = agent.subscribe?.({
onRunInitialized: () => setStopped(null),
onRunErrorEvent: ({ event }) => setStopped(stoppedReason(event?.message)),
onRunFailed: ({ error }) => setStopped(stoppedReason(error)),
});
return () => subscription?.unsubscribe();
}, [agent]);

return stopped;
}
27 changes: 27 additions & 0 deletions app/src/routes/_authed/_app/bot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CopilotChat } from "@copilotkit/react-core/v2";
import { createFileRoute } from "@tanstack/react-router";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { useBotThread } from "@/lib/copilot/bot-thread";
import { useStoppedTurn } from "@/lib/copilot/stopped-turn";

export const Route = createFileRoute("/_authed/_app/bot")({
component: RouteComponent,
Expand All @@ -18,6 +19,15 @@ function RouteComponent() {
useActiveBot(agentId);
// Minted by this deployment rather than by the chat, and the same one on the next visit.
const threadId = useBotThread(agentId);
/*
* A turn that ends without an answer has to be said out loud here, because the packaged chat says
* nothing. It reports a failed run to an `onError` prop and otherwise carries on as though the
* turn simply finished: the composer unlocks, the spinner goes, and the transcript keeps the
* person's own message with nothing under it. The banner that would have explained it belongs to
* a provider this app does not mount.
*/
const stopped = useStoppedTurn(agentId);

return (
<div className="flex h-screen flex-col">
<header className="border-b px-6 py-3">
Expand All @@ -26,6 +36,23 @@ function RouteComponent() {
Ask it to open a page and watch it work.
</p>
</header>
{/*
* Under the header rather than at the end of the transcript, which is where the missing answer
* was going to be and where the channel draws its own version of this. The packaged chat owns
* that list and virtualises it, so reaching into it means replacing the whole message view and
* taking on its scrolling. The cost of putting the sentence here instead is that it is not
* beside the gap it explains; what it buys is that it is always on screen, whatever the
* transcript has been scrolled to, and that it survives the next release of the chat.
*/}
{stopped ? (
<p
className="border-b bg-destructive/10 px-6 py-2 text-destructive text-sm"
data-testid="bot-chat-stopped"
role="alert"
>
{stopped}
</p>
) : null}
<div className="min-h-0 flex-1">
{/* Remount when switching Bots so chat state stays bound to the selected agent. */}
{threadId ? (
Expand Down
Loading