Skip to content

Commit 78eb23a

Browse files
committed
Merge main into the stall watchdog
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.
2 parents 75b2140 + 93ff1b1 commit 78eb23a

28 files changed

Lines changed: 2258 additions & 29 deletions

.env.example

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ AGENT_STALL_TIMEOUT_MS=60000
7676
# framework Bot unless you point it at another provider below.
7777
OPENAI_API_KEY=
7878

79+
# Where that key is spent. Unset, it is OpenAI. Set, it is any endpoint speaking the same
80+
# `/v1/chat/completions` API: a gateway in front of several providers, a proxy, or a model on
81+
# hardware you control. The API server reads it for the built-in agents and both shipped Bots read
82+
# it too, so one line moves the whole deployment rather than one Bot.
83+
#
84+
# Model names travel verbatim, so use whatever the endpoint publishes. An endpoint that namespaces
85+
# its catalogue wants both halves of the name, in `BOT_MODEL` and in the tenant package's
86+
# `default_model` alike:
87+
#
88+
# OPENAI_BASE_URL=https://gateway.internal/v1
89+
# OPENAI_API_KEY=...
90+
# BOT_MODEL=openai/gpt-4o
91+
#
92+
# OPENAI_BASE_URL=
93+
94+
# The same for the other two providers, under the names the API server already reads. They are
95+
# different APIs rather than different URLs for this one, so each has its own.
96+
# ANTHROPIC_BASE_URL=
97+
# GOOGLE_GENERATIVE_AI_BASE_URL=
98+
7999
# Framework Bot provider: openai, anthropic or google. It reads that provider's own
80100
# key and refuses to start without it, so a deployment on Anthropic never needs an OpenAI key for it.
81101
# The proof-of-concept Bot is OpenAI only by construction: it speaks that API directly.
@@ -113,7 +133,17 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
113133
# which Bot took it. A rule can still restrict a single Bot with `bot.id`.
114134
#
115135
# Attributes: tool.name, bot.id, actor.id, page.url, page.host, element.ref/role/name/type,
116-
# key, file.path, file.name, file.extension.
136+
# key, file.path, file.name, file.extension, repeat.count.
137+
#
138+
# repeat.count is how many times this Bot has just made this exact call, counting the one being
139+
# decided. A stuck model retries, and each retry is a real action on somebody's live website that is
140+
# perfectly reasonable on its own terms; only the count tells the thirtieth click apart from the
141+
# first. `repeat.count >= 10` in `deny` stops a Bot going in circles. Two calls are the same call
142+
# when the thing acted on is the same, whatever was typed into it, so ten searches typed into one box
143+
# are ten repeats and a rule about repetition refuses the tenth: try one in `dry-run` first. The
144+
# count is held in memory by the process that served the call, so a deployment running two API
145+
# replicas splits every count and a rule about ten attempts fires at twenty or never, and calls to
146+
# another server's tools over MCP are not counted at all.
117147
#
118148
# Name every route to the same effect. A form submits from a keypress in any of its fields, so a rule
119149
# that only blocks a Submit button does not block Enter from another field. The example below refuses
@@ -124,6 +154,16 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
124154
#
125155
# AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || (tool.name == \"computer_key\" && key == \"Enter\")"],"allow":["true"]}
126156

157+
# How long two identical calls count as the same repetition, in ms. Three minutes unset, which
158+
# assumes a retry loop is a model round trip apart: call the tool, read the failure, try again.
159+
# Widen it for a deployment whose provider is slow or heavily queued, where genuine retries arrive
160+
# minutes apart and every attempt would otherwise be counted as the first one. Widen it too far and
161+
# honest work starts to accumulate: a Bot told to watch a dashboard all morning reloads the same page
162+
# and is not stuck. Anything that is not a positive whole number stops the server rather than falling
163+
# back to the default, because a rule about repetition that never fires looks exactly like a Bot
164+
# behaving itself.
165+
# COMPUTER_REPEAT_WINDOW_MS=180000
166+
127167
# How long one action waits for its element, in ms. Read by agent-computer, not the server.
128168
# ACTION_TIMEOUT_MS=10000
129169

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ Settings worth knowing:
175175
| Variable | Use |
176176
| ------------------------------------ | ------------------------------------------------------------------------- |
177177
| `OPENBOT_DEV_NO_AUTH` | Admits every request as one administrator. How OpenBot runs today. |
178+
| `OPENAI_BASE_URL` | Answers the OpenAI-shaped calls from somewhere else: a gateway, a proxy. |
179+
| `ANTHROPIC_BASE_URL`, `GOOGLE_GENERATIVE_AI_BASE_URL` | The same, for those two APIs. |
178180
| `COMPUTER_TOKEN` | Secret every Bot computer request must present. `start.sh` sets one. |
179181
| `SUPERVISOR_TOKEN` | Secret the supervisor requires. `start.sh` sets one. |
180182
| `COMPUTER_SUPERVISOR_URL` | Gives each Bot a computer of its own instead of one shared computer. |

agent-bot/src/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,21 @@ const PORT = Number.parseInt(process.env.PORT ?? "4200", 10);
2626
*/
2727
const MODEL = process.env.BOT_MODEL ?? "gpt-5.5";
2828

29-
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
29+
/**
30+
* Where that model is answered from.
31+
*
32+
* Unset, this is OpenAI. Set, it is any endpoint speaking the same `/v1/chat/completions` API: a
33+
* gateway in front of several providers, a proxy, or a model on hardware you control. Which is the
34+
* point of writing against that API by hand rather than against one company's URL.
35+
*
36+
* `BOT_MODEL` is sent verbatim, because an endpoint names its own catalogue.
37+
*/
38+
const BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined;
39+
40+
const openai = new OpenAI({
41+
apiKey: process.env.OPENAI_API_KEY,
42+
baseURL: BASE_URL,
43+
});
3044

3145
/** Translate the conversation AG-UI carries into the shape the model provider expects. */
3246
function toProviderMessages(input: RunAgentInput) {

agent-langgraph/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ const PROVIDER = (process.env.BOT_PROVIDER ?? "openai").toLowerCase();
5858
const MODEL = process.env.BOT_MODEL ?? defaultModelFor(PROVIDER);
5959
/** OpenAI only. Its newer models require the Responses API, which the integration handles. */
6060
const USE_RESPONSES_API = process.env.BOT_RESPONSES_API === "true";
61+
/**
62+
* OpenAI only, and the same variable the API server reads for its built-in agents.
63+
*
64+
* Unset, `openai` means OpenAI. Set, it means any endpoint speaking that API: a gateway in front of
65+
* several providers, a proxy, or a model on hardware you control. The integration owns the HTTP, so
66+
* this is a base URL rather than another provider branch, and `BOT_MODEL` is sent verbatim because
67+
* an endpoint names its own catalogue.
68+
*/
69+
const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL?.trim() || undefined;
70+
/**
71+
* The same idea for the other two providers, under the names the API server already reads.
72+
*
73+
* Sharing the variable names is the point: one line moves the built-in agents and this Bot
74+
* together, and a deployment cannot end up with half of itself pointed somewhere else.
75+
*/
76+
const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL?.trim() || undefined;
77+
const GOOGLE_BASE_URL =
78+
process.env.GOOGLE_GENERATIVE_AI_BASE_URL?.trim() || undefined;
6179

6280
function defaultModelFor(provider: string): string {
6381
if (provider === "anthropic") return "claude-sonnet-4-5";
@@ -177,19 +195,22 @@ function buildModel() {
177195
model: MODEL,
178196
apiKey: API_KEY,
179197
streaming: true,
198+
...(ANTHROPIC_BASE_URL ? { anthropicApiUrl: ANTHROPIC_BASE_URL } : {}),
180199
});
181200
}
182201
if (PROVIDER === "google") {
183202
return new ChatGoogleGenerativeAI({
184203
model: MODEL,
185204
apiKey: API_KEY,
186205
streaming: true,
206+
...(GOOGLE_BASE_URL ? { baseUrl: GOOGLE_BASE_URL } : {}),
187207
});
188208
}
189209
return new ChatOpenAI({
190210
model: MODEL,
191211
apiKey: API_KEY,
192212
streaming: true,
213+
...(OPENAI_BASE_URL ? { configuration: { baseURL: OPENAI_BASE_URL } } : {}),
193214
...(USE_RESPONSES_API ? { useResponsesApi: true } : {}),
194215
});
195216
}

app/src/components/channels/channel-chat.tsx

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,33 @@ export function ChannelChat({
142142
const [runError, setRunError] = useState<string | null>(null);
143143
const awaitingReply = useRef(false);
144144

145+
/*
146+
* TWO DIFFERENT FACTS ABOUT ONE TURN, AND NEITHER OF THEM IS `agent.isRunning`.
147+
*
148+
* `turnsInFlight` counts what a person would call the Bot having the turn: from the moment `say`
149+
* is entered until the whole thing has come back, browser actions in the middle included. It is
150+
* what decides whether the next thing typed is sent or parked, and what tells the queue its wait
151+
* is over.
152+
*
153+
* `runsInFlight` counts what Stop can actually reach: the run `copilotkit.runAgent` opens, and
154+
* nothing before it. A turn can be in flight for a second and a half before that, while `say`
155+
* waits for the runtime agent, and a Stop drawn in that window aborts a controller nobody has
156+
* made yet.
157+
*
158+
* `agent.isRunning` looks like both and is neither. It reports the run on the wire, and a turn
159+
* that touches the browser is several runs in a row: the Bot asks for a click, the run ENDS so
160+
* the browser can answer it, and another run starts carrying the answer. The agent reports itself
161+
* idle in every one of those gaps — the truth about the wire and a lie about the turn. OpenBot
162+
* registers every computer tool as a frontend tool, so the gaps open on ordinary work rather than
163+
* on some edge case, and anything keyed on the turn ending fires in the middle of one instead.
164+
*
165+
* Counters rather than booleans because nothing stops a second turn being started from a
166+
* component button while the first is still going, and two overlapping turns must not have the
167+
* first one to finish declare the conversation idle.
168+
*/
169+
const [turnsInFlight, setTurnsInFlight] = useState(0);
170+
const [runsInFlight, setRunsInFlight] = useState(0);
171+
145172
/**
146173
* Tell the roster what was just said. Failures here must not block the conversation.
147174
*/
@@ -160,12 +187,10 @@ export function ChannelChat({
160187
reportRef.current = report;
161188

162189
/**
163-
* Send a user turn through the channel, including activity reporting and history repair.
190+
* Everything `say` does once it has something worth sending, split out so the counter it is
191+
* wrapped in covers every way out of here, a throw included.
164192
*/
165-
const say = async (text: string, skillInstructions: string[] = []) => {
166-
const trimmed = text.trim();
167-
if (!trimmed) return;
168-
193+
const deliver = async (trimmed: string, skillInstructions: string[]) => {
169194
// Wait briefly for the runtime agent instance before adding the message.
170195
if (!isReadyRef.current) {
171196
await Promise.race([
@@ -212,7 +237,32 @@ export function ChannelChat({
212237
agent.setMessages(repaired as typeof agent.messages);
213238
}
214239

215-
await copilotkit.runAgent({ agent });
240+
setRunsInFlight((count) => count + 1);
241+
try {
242+
await copilotkit.runAgent({ agent });
243+
} finally {
244+
setRunsInFlight((count) => count - 1);
245+
}
246+
};
247+
248+
/**
249+
* Send a user turn through the channel, including activity reporting and history repair.
250+
*
251+
* Every user turn in this channel goes through here — what the composer sends, the seed from the
252+
* compose screen, and a button inside a rendered component. That is what makes the counter worth
253+
* keeping here rather than in the view: the view sees only the turns it started itself, and a
254+
* queue that drains on the wrong one of those posts a correction into the middle of an answer.
255+
*/
256+
const say = async (text: string, skillInstructions: string[] = []) => {
257+
const trimmed = text.trim();
258+
if (!trimmed) return;
259+
260+
setTurnsInFlight((count) => count + 1);
261+
try {
262+
await deliver(trimmed, skillInstructions);
263+
} finally {
264+
setTurnsInFlight((count) => count - 1);
265+
}
216266
};
217267

218268
useEffect(() => {
@@ -321,7 +371,26 @@ export function ChannelChat({
321371
awaitingReply.current = false;
322372
copilotkit.stopAgent({ agent });
323373
}}
324-
pending={agent.isRunning}
374+
/*
375+
* The turn, not the run. A browser action ends one run and starts another, and telling the
376+
* conversation it is idle in between is what would drain a parked correction into the
377+
* middle of an answer: a second turn racing the first on one thread, with a fabricated
378+
* result stitched over a tool call that is still executing.
379+
*/
380+
pending={agent.isRunning || turnsInFlight > 0}
381+
/*
382+
* A channel outlives its turns, so it is the screen where waiting is worth offering. A
383+
* correction typed mid-answer is held here, in this tab, and runs as one follow-up turn the
384+
* moment this one is over — including when it is over because somebody pressed the button
385+
* above.
386+
*/
387+
queueWhileBusy
388+
/*
389+
* The run, not the turn. Stop reaches a run through the core's abort controller, and that
390+
* controller does not exist until `say` has finished waiting for the runtime agent — so
391+
* this is the one place the narrower fact is the honest one to draw a button from.
392+
*/
393+
stoppable={agent.isRunning || runsInFlight > 0}
325394
/*
326395
* At the END OF THE TRANSCRIPT rather than above the composer, which is where this used to
327396
* be. A turn that ends without an answer leaves a gap exactly where the reply was going to

0 commit comments

Comments
 (0)