Skip to content

Commit 3f5f409

Browse files
authored
Put the AG-UI Bots back on a model that answers (#175)
* Fix three things the from-scratch gate found The router's model call sent temperature 0. Reasoning models refuse the setting outright, and this call treats a throw as "not sure", so every routing decision quietly became the default coworker and the roster was never consulted. A question naming Google Drive went to a Bot holding no Drive tools. Omitted rather than set per model, because a list of which models accept it is a list that goes stale. The fallback now asks whether the message named a system exactly one coworker can reach, before it reaches for the default. Every path into that fallback is "we are not sure", and the default is a guess; when one coworker holds the named system and the others cannot answer at all, it is not. Only when unambiguous, and a confident match still wins, because a specialist with no connectors is the right answer to a question about its specialism. agent-bot was running gpt-5.6-terra. I wrote that it stays on gpt-5.5 and then changed both compose lines with one edit, so the Bot in the box was handed a model whose function tools that endpoint refuses. It has its own variable now, and it refuses to start on a model it cannot use rather than failing one tool call at a time with no reply and no reason. Its history builder no longer trusts the order it is given. Read back from the durable thread store a tool result arrives BEFORE the assistant message that made the call, and the call's name is missing: a payload no provider accepts, answered with silence rather than an error. Calls and results are paired here instead. * Put the AG-UI Bots back on a model that answers Risk Analyst went silent. Every question, tools or not: the run reached the server, returned 200, and no text ever came back. No error, nothing logged, nothing on screen. It is the framework Bot, on 4201, not the hand-written one on 4200. I defaulted it to gpt-5.6-terra and inferred the Responses API for it, on the strength of a comment in this repo saying that integration could take 5.6 in one line. I never drove it. Called directly it answers RUN_STARTED, then RUN_FINISHED, and nothing in between. On gpt-5.5 the same call answers. So both AG-UI Bots default to gpt-5.5, each for its own measured reason, and both reasons are now written where the default is. The built-in Bots keep gpt-5.6-terra, which is the one place 5.6 is proven and the place the bug that started this was. Setting BOT_MODEL to a 5.6 model still works and still switches the Responses API on by itself. Also here, from the same gate run: The router sent temperature 0, which reasoning models refuse outright, and this call treats a throw as "not sure". Every routing decision quietly became the default coworker and the roster was never consulted: a question naming Google Drive went to a Bot holding no Drive tools. Its fallback now asks whether the message named a system exactly one coworker can reach, before reaching for the default. agent-bot was handed gpt-5.6-terra too. It refuses to start on a model whose function tools its endpoint rejects, rather than failing one silent tool call at a time, and it has its own variable so the other Bot's model cannot take its tools away. Its history builder no longer trusts the order it is given. Read back from the durable store, a tool result arrives before the call it answers and the call's name is missing: a payload no provider accepts, answered with silence. joinWithin waited on a detached connect forever. A connect that ignores the detach left the gate shut and every later message waiting on it, which is silence of a different kind. Bounded now. Driven end to end: an untagged question about a Drive document routes to the coworker holding Drive, searches, reads, and answers with all five proxy metrics and their exact targets, checked against the document read out of band.
1 parent 0d303a2 commit 3f5f409

15 files changed

Lines changed: 437 additions & 49 deletions

File tree

.env.example

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ OPENAI_API_KEY=
125125
#
126126
# OPENAI_BASE_URL=https://gateway.internal/v1
127127
# OPENAI_API_KEY=...
128-
# BOT_MODEL=openai/gpt-5.6-terra
128+
# BOT_MODEL=openai/gpt-5.5
129129
#
130130
# OPENAI_BASE_URL=
131131

@@ -141,9 +141,11 @@ OPENAI_API_KEY=
141141
# ANTHROPIC_API_KEY=
142142
# GOOGLE_API_KEY=
143143

144-
# Which model. Defaults per provider: gpt-5.6-terra, claude-sonnet-4-5, gemini-2.5-flash.
145-
# OpenAI's 5.6 tiers are sol (most capable), terra (the default here) and luna (cheapest).
146-
# BOT_MODEL=gpt-5.6-terra
144+
# Which model the framework Bot uses. Defaults per provider: gpt-5.5, claude-sonnet-4-5,
145+
# gemini-2.5-flash. Not a 5.6 tier: this integration answers nothing at all on gpt-5.6-* through the
146+
# Responses API, driven against the real service. Set one here to try it and the Responses API is
147+
# switched on automatically. The built-in Bots do run 5.6, through the package's model.yaml.
148+
# BOT_MODEL=gpt-5.5
147149

148150
# OpenAI only, and rarely needed: the framework Bot turns the Responses API on by itself for models
149151
# that require it. Set it when you are using a model this build has not heard of that needs it too.
@@ -238,10 +240,13 @@ MANAGED_AGENT_TOKEN=
238240
# proof of concept, and is reached the same way: point MANAGED_AGENT_AG_UI_URL at it, or add it as a
239241
# Bot of its own in the tenant package or at /agents.
240242

241-
# Which model the Bots use. agent-langgraph runs gpt-5.6-terra and switches to the Responses API by
242-
# itself, because 5.6 rejects function tools on /v1/chat/completions. agent-bot speaks that endpoint
243-
# by hand and stays on gpt-5.5: the alternative there is reasoning_effort 'none', and a Bot that has
244-
# to decide when to ask a person for help should not be the one with its reasoning turned off.
243+
# Which model the Bots use. BOT_MODEL is the framework Bot's: it runs gpt-5.6-terra and switches to
244+
# the Responses API by itself, because 5.6 rejects function tools on /v1/chat/completions.
245+
#
246+
# The proof-of-concept Bot has its own, AGENT_BOT_MODEL, defaulting to gpt-5.5, because it writes
247+
# that endpoint by hand and refuses to start on a model whose tools it cannot use. One variable for
248+
# both would mean setting the framework Bot's model quietly took the other one's tools away.
249+
# AGENT_BOT_MODEL=gpt-5.5
245250
# BOT_RESPONSES_API=false
246251

247252
# One computer per Bot. Unset, every Bot shares the computer at AGENT_COMPUTER_URL, suitable on a

CHANGELOG.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,18 @@ Sessions survive and nobody signs in again.
235235
shipped `gpt-4.1` as the default for every built-in Bot. Asked to open a page behind a sign-in,
236236
those Bots answered "would you like me to prompt you to sign in?" and called nothing, three times
237237
out of three, while the prompt forbids that sentence in as many words. On `gpt-5.6-terra` the same
238-
question produces the tool call first try. The default is now `gpt-5.6-terra` across the package,
239-
the compose services and both example Bots, and the Responses API is inferred from the model rather
240-
than left to a separate switch, because `gpt-5.6-*` rejects function tools on chat completions and
241-
a deployment that set the model without knowing that got a Bot which started, looked healthy, and
242-
failed on its first tool call. It is a default, not a commitment: `BOT_MODEL` and the package's
243-
`model.yaml` still decide. `agent-bot` stays on `gpt-5.5` on purpose, since the only ways to 5.6 on
244-
the endpoint it writes by hand are a streaming rewrite or turning reasoning off, and it is the Bot
245-
whose job includes deciding when to ask a person for help.
238+
question produces the tool call first try, so the package now runs `gpt-5.6-terra`. It is a
239+
default, not a commitment: `model.yaml` still decides.
240+
241+
The Bots that answer over AG-UI stay on `gpt-5.5`, each for its own measured reason. The framework
242+
Bot answers nothing at all on `gpt-5.6-*` through the Responses API — `RUN_STARTED`, then
243+
`RUN_FINISHED`, no text — and the hand-written one cannot use function tools on
244+
`/v1/chat/completions` with a 5.6 model unless reasoning is turned off, which is the wrong trade
245+
for a Bot whose job includes deciding when to ask a person for help. It refuses to start on such a
246+
model now rather than failing one silent tool call at a time. Where a 5.6 model is set deliberately,
247+
the Responses API is switched on for it automatically, because a deployment that set the model and
248+
did not know about that switch got a Bot which started, looked healthy, and failed on its first
249+
tool call.
246250
- **A Bot browsed to a vendor this deployment already connects to.** A Bot holding no grants was told
247251
nothing about connectors at all, so it treated a connected vendor as an ordinary website: asked
248252
about Google Drive it opened `drive.google.com`, met a sign-in page, and asked the person to sign

agent-bot/src/history.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,25 @@ export function toProviderMessages(
3232
.filter((id): id is string => Boolean(id)),
3333
);
3434

35+
/*
36+
* Tool results, by the call they answer.
37+
*
38+
* The history is not guaranteed to arrive with a result after the call it belongs to. Read back
39+
* from the durable thread store it arrives the other way round, result first, which is a payload
40+
* no provider accepts: a tool message with no preceding call, and then a call with nothing
41+
* following it. The model answers that with silence rather than an error, which is the worst of
42+
* both, so the pairing is rebuilt here instead of trusted.
43+
*/
44+
const resultsByCall = new Map<string, string>();
3545
for (const message of input.messages) {
46+
if (message.role !== "tool") continue;
47+
const id = (message as { toolCallId?: string }).toolCallId;
48+
if (id) resultsByCall.set(id, String(message.content ?? ""));
49+
}
50+
51+
for (const message of input.messages) {
52+
// Placed with the call they answer, below, rather than wherever they arrived.
53+
if (message.role === "tool") continue;
3654
if (message.role === "user") {
3755
messages.push({ role: "user", content: String(message.content ?? "") });
3856
continue;
@@ -41,22 +59,19 @@ export function toProviderMessages(
4159
messages.push({ role: "system", content: String(message.content ?? "") });
4260
continue;
4361
}
44-
if (message.role === "tool") {
45-
// Tool results are appended so the model can continue from the completed call.
46-
messages.push({
47-
role: "tool",
48-
tool_call_id: message.toolCallId,
49-
content: String(message.content ?? ""),
50-
});
51-
continue;
52-
}
5362
if (message.role === "assistant") {
5463
const toolCalls = message.toolCalls?.map((call) => ({
5564
id: call.id,
5665
type: "function" as const,
5766
function: {
58-
name: call.function.name,
59-
arguments: call.function.arguments,
67+
/*
68+
* A name is required by the provider and is not always present: read back from the thread
69+
* store these arrive undefined, and a payload carrying `"name": undefined` is rejected
70+
* outright. The call still has to be shown, or the model repeats an action it already
71+
* took, so it keeps its id and is named as something the model can read.
72+
*/
73+
name: call.function?.name ?? "tool",
74+
arguments: call.function?.arguments ?? "{}",
6075
},
6176
}));
6277
messages.push({
@@ -72,14 +87,22 @@ export function toProviderMessages(
7287
* call, so these go here rather than being appended at the end. A call answered later in the
7388
* history is left alone and its real answer arrives in its own turn.
7489
*/
90+
/*
91+
* Every call this message made, answered, immediately after it.
92+
*
93+
* The real result where there is one, wherever it arrived in the input, and `NO_ANSWER_CAME`
94+
* where there is not. Both cases are the same requirement: a call must be followed by its
95+
* result, and the provider rejects the message outright otherwise.
96+
*/
7597
for (const call of message.toolCalls ?? []) {
76-
if (call.id && !answered.has(call.id)) {
77-
messages.push({
78-
role: "tool",
79-
tool_call_id: call.id,
80-
content: NO_ANSWER_CAME,
81-
});
82-
}
98+
if (!call.id) continue;
99+
messages.push({
100+
role: "tool",
101+
tool_call_id: call.id,
102+
content: answered.has(call.id)
103+
? (resultsByCall.get(call.id) ?? "")
104+
: NO_ANSWER_CAME,
105+
});
83106
}
84107
}
85108
}

agent-bot/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ if (!MANAGED_AGENT_TOKEN) {
3333
* chat-completions streaming loop.
3434
*/
3535
const MODEL = process.env.BOT_MODEL ?? "gpt-5.5";
36+
/*
37+
* Refuse a model this file cannot use, rather than discover it one tool call at a time.
38+
*
39+
* `gpt-5.6-*` rejects function tools on `/v1/chat/completions`: "To use function tools, use
40+
* /v1/responses or set reasoning_effort to 'none'." The provider answers with an error, this Bot
41+
* ends the run, and the person sees no reply and no reason. Silence is the worst failure available
42+
* here, and it is what a single mistaken `BOT_MODEL` produced: every tool-using turn stopped dead
43+
* while the Bot looked healthy.
44+
*
45+
* Startup is where a deployment can act on it, which is the same posture as the token check above.
46+
*/
47+
if (/^gpt-5\.[6-9]|^gpt-[6-9]/.test(MODEL)) {
48+
console.error(
49+
`BOT_MODEL=${MODEL} cannot be used by this Bot. It speaks /v1/chat/completions directly, and ` +
50+
"that endpoint refuses function tools for this model, so every tool call would fail with no " +
51+
"reply. Use gpt-5.5, or the framework Bot on port 4201, which speaks the Responses API.",
52+
);
53+
process.exit(1);
54+
}
3655

3756
/**
3857
* Where that model is answered from.

agent-bot/tests/history.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,64 @@ describe("a tool call nothing ever answered", () => {
146146
expect(ids).toEqual(["c1", "c2"]);
147147
});
148148
});
149+
150+
/**
151+
* The history as the durable thread store hands it back.
152+
*
153+
* Read back from a stored thread, a tool result arrives BEFORE the assistant message that made the
154+
* call, and the call's `function.name` is missing. Both are payloads a provider rejects: a tool
155+
* message with no preceding call, and a call with nothing following it. The model answers that with
156+
* silence rather than an error, so a Bot that had just read a document said nothing at all and the
157+
* conversation looked dead.
158+
*
159+
* The exact shape below was copied off a real thread after a Google Drive answer went missing.
160+
*/
161+
describe("a history that arrives out of order", () => {
162+
test("pairs each call with its result, whatever order they arrived in", () => {
163+
const messages = withoutGuidance(
164+
toProviderMessages(
165+
input([
166+
{ id: "1", role: "user", content: "What is in the PRD?" } as Message,
167+
{
168+
id: "2",
169+
role: "tool",
170+
toolCallId: "c1",
171+
content: "the document text",
172+
} as unknown as Message,
173+
{
174+
id: "3",
175+
role: "assistant",
176+
content: "",
177+
toolCalls: [call("c1", "read_file_content")],
178+
} as unknown as Message,
179+
]),
180+
),
181+
);
182+
183+
// Assistant first, then its result. Never a tool message with no call before it.
184+
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "tool"]);
185+
const answer = messages[2] as { tool_call_id?: string; content?: string };
186+
expect(answer.tool_call_id).toBe("c1");
187+
expect(answer.content).toBe("the document text");
188+
});
189+
190+
test("gives a nameless call a name, because the provider requires one", () => {
191+
const messages = withoutGuidance(
192+
toProviderMessages(
193+
input([
194+
{
195+
id: "1",
196+
role: "assistant",
197+
content: "",
198+
toolCalls: [{ id: "c1", type: "function", function: {} }],
199+
} as unknown as Message,
200+
]),
201+
),
202+
);
203+
204+
const assistant = messages[0] as {
205+
tool_calls?: { function: { name: string } }[];
206+
};
207+
expect(assistant.tool_calls?.[0]?.function.name).toBe("tool");
208+
});
209+
});

agent-langgraph/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const GOOGLE_BASE_URL =
100100
function defaultModelFor(provider: string): string {
101101
if (provider === "anthropic") return "claude-sonnet-4-5";
102102
if (provider === "google") return "gemini-2.5-flash";
103-
return "gpt-5.6-terra";
103+
return "gpt-5.5";
104104
}
105105

106106
/**

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
} from "@copilotkit/react-core/v2";
77
import { useMutation, useQuery } from "@tanstack/react-query";
88
import { useCallback, useEffect, useRef, useState } from "react";
9-
import { readThreadMessages } from "@/lib/copilot/thread-messages";
109
import { toAgentOptions } from "@/components/channels/composer";
1110
import { ConversationView } from "@/components/channels/conversation-view";
1211
import {
@@ -22,6 +21,7 @@ import { ConversationProvider } from "@/lib/copilot/conversation";
2221
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
2322
import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
2423
import { stoppedReason } from "@/lib/copilot/stopped-turn";
24+
import { readThreadMessages } from "@/lib/copilot/thread-messages";
2525
import { useSkillCommands } from "@/lib/plugins/skill-commands";
2626
import { newId } from "../../lib/new-id";
2727

@@ -125,12 +125,20 @@ export function ChannelChat({
125125
let current = true;
126126

127127
void (async () => {
128-
// Bounded, and finished when it returns; `join-thread.ts` has why that matters.
129-
await joinWithin({
130-
connect: copilotkit.connectAgent({ agent }),
131-
deadline: afterMs(JOIN_DEADLINE_MS),
132-
detach: () => agent.detachActiveRun(),
133-
});
128+
try {
129+
// Bounded, and finished when it returns; `join-thread.ts` has why that matters.
130+
await joinWithin({
131+
connect: copilotkit.connectAgent({ agent }),
132+
deadline: afterMs(JOIN_DEADLINE_MS),
133+
detach: () => agent.detachActiveRun(),
134+
});
135+
} catch {
136+
/*
137+
* A join that throws is a join that is over. It must not take the gate with it: everything
138+
* typed afterwards waits on that gate, so a throw here would silence the conversation
139+
* rather than degrade it. History is restored below either way.
140+
*/
141+
}
134142

135143
try {
136144
const stored = await readThreadMessages(

app/src/lib/copilot/join-thread.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,30 @@ export async function joinWithin({
3939
// A detach with nothing to detach is not a problem worth reporting, and the wait below is what
4040
// this function actually promises. Swallowing it here keeps that promise on both paths.
4141
}
42-
await finished;
42+
/*
43+
* Bounded, because a detach is a request and not a guarantee.
44+
*
45+
* This was a bare `await finished`, on the reasoning that a detached connect ends promptly. When
46+
* it does not, nothing here ever returns: the caller's `finally` never runs, the gate it opens
47+
* stays shut, and every message typed afterwards waits on it forever. That is silence — the
48+
* message appears in the transcript, no run is ever started, no request reaches the server and
49+
* nothing is logged, which is the hardest failure of all to read.
50+
*
51+
* A connect still running after this grace has outlived its usefulness either way. Going on
52+
* without it risks the overwrite this function exists to prevent; waiting for it risks a
53+
* conversation that never answers again. The first is recoverable and visible. The second is not.
54+
*/
55+
await Promise.race([finished, afterMs(DETACH_GRACE_MS)]);
4356
}
4457

58+
/**
59+
* How long a detached connect is given to finish before the turn goes ahead regardless.
60+
*
61+
* Long enough that an ending connect is waited for, short enough that a stuck one is not the end of
62+
* the conversation.
63+
*/
64+
const DETACH_GRACE_MS = 2_000;
65+
4566
/** A deadline, as a promise. Separate so a test can supply one it controls. */
4667
export function afterMs(milliseconds: number): Promise<void> {
4768
return new Promise((resolve) => setTimeout(resolve, milliseconds));

app/tests/join-thread.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,61 @@ describe("joinWithin", () => {
121121
expect(done).toBe(true);
122122
});
123123
});
124+
125+
/**
126+
* A connect that does not end when it is asked to.
127+
*
128+
* `detach` is a request, not a guarantee. The wait after it used to be unbounded, on the reasoning
129+
* that a detached connect ends promptly, and when it does not nothing here ever returns: the
130+
* caller's `finally` never runs, the gate it opens stays shut, and every message typed afterwards
131+
* waits on it forever.
132+
*
133+
* That failure is silent. The message appears in the transcript, no run is started, no request
134+
* reaches the server, and nothing is logged. Two Bots sat mute through a whole gate run before this
135+
* was found, and the only symptom was an answer that never came.
136+
*/
137+
describe("a detached connect that never finishes", () => {
138+
test("does not hold the turn forever", async () => {
139+
let ended = false;
140+
const never = new Promise<void>(() => {});
141+
142+
const settled = joinWithin({
143+
connect: never,
144+
deadline: Promise.resolve(),
145+
detach: async () => {
146+
// Asked, and ignored, which is the case this exists for.
147+
},
148+
}).then(() => {
149+
ended = true;
150+
});
151+
152+
await Promise.race([
153+
settled,
154+
new Promise((resolve) => setTimeout(resolve, 4_000)),
155+
]);
156+
157+
expect(ended).toBe(true);
158+
}, 10_000);
159+
160+
test("still waits for a detached connect that does finish", async () => {
161+
// The behaviour the grace must not throw away: a connect that ends is waited for, so nothing is
162+
// left in flight to overwrite the message.
163+
let finished = false;
164+
let end: () => void = () => {};
165+
const connect = new Promise<void>((resolve) => {
166+
end = () => {
167+
finished = true;
168+
resolve();
169+
};
170+
});
171+
172+
const settled = joinWithin({
173+
connect,
174+
deadline: Promise.resolve(),
175+
detach: async () => end(),
176+
});
177+
178+
await settled;
179+
expect(finished).toBe(true);
180+
});
181+
});

0 commit comments

Comments
 (0)