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
21 changes: 13 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ OPENAI_API_KEY=
#
# OPENAI_BASE_URL=https://gateway.internal/v1
# OPENAI_API_KEY=...
# BOT_MODEL=openai/gpt-5.6-terra
# BOT_MODEL=openai/gpt-5.5
#
# OPENAI_BASE_URL=

Expand All @@ -141,9 +141,11 @@ OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=

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

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

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

# One computer per Bot. Unset, every Bot shares the computer at AGENT_COMPUTER_URL, suitable on a
Expand Down
20 changes: 12 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,18 @@ Sessions survive and nobody signs in again.
shipped `gpt-4.1` as the default for every built-in Bot. Asked to open a page behind a sign-in,
those Bots answered "would you like me to prompt you to sign in?" and called nothing, three times
out of three, while the prompt forbids that sentence in as many words. On `gpt-5.6-terra` the same
question produces the tool call first try. The default is now `gpt-5.6-terra` across the package,
the compose services and both example Bots, and the Responses API is inferred from the model rather
than left to a separate switch, because `gpt-5.6-*` rejects function tools on chat completions and
a deployment that set the model without knowing that got a Bot which started, looked healthy, and
failed on its first tool call. It is a default, not a commitment: `BOT_MODEL` and the package's
`model.yaml` still decide. `agent-bot` stays on `gpt-5.5` on purpose, since the only ways to 5.6 on
the endpoint it writes by hand are a streaming rewrite or turning reasoning off, and it is the Bot
whose job includes deciding when to ask a person for help.
question produces the tool call first try, so the package now runs `gpt-5.6-terra`. It is a
default, not a commitment: `model.yaml` still decides.

The Bots that answer over AG-UI stay on `gpt-5.5`, each for its own measured reason. The framework
Bot answers nothing at all on `gpt-5.6-*` through the Responses API — `RUN_STARTED`, then
`RUN_FINISHED`, no text — and the hand-written one cannot use function tools on
`/v1/chat/completions` with a 5.6 model unless reasoning is turned off, which is the wrong trade
for a Bot whose job includes deciding when to ask a person for help. It refuses to start on such a
model now rather than failing one silent tool call at a time. Where a 5.6 model is set deliberately,
the Responses API is switched on for it automatically, because a deployment that set the model and
did not know about that switch got a Bot which started, looked healthy, and failed on its first
tool call.
- **A Bot browsed to a vendor this deployment already connects to.** A Bot holding no grants was told
nothing about connectors at all, so it treated a connected vendor as an ordinary website: asked
about Google Drive it opened `drive.google.com`, met a sign-in page, and asked the person to sign
Expand Down
59 changes: 41 additions & 18 deletions agent-bot/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,25 @@ export function toProviderMessages(
.filter((id): id is string => Boolean(id)),
);

/*
* Tool results, by the call they answer.
*
* The history is not guaranteed to arrive with a result after the call it belongs to. Read back
* from the durable thread store it arrives the other way round, result first, which is a payload
* no provider accepts: a tool message with no preceding call, and then a call with nothing
* following it. The model answers that with silence rather than an error, which is the worst of
* both, so the pairing is rebuilt here instead of trusted.
*/
const resultsByCall = new Map<string, string>();
for (const message of input.messages) {
if (message.role !== "tool") continue;
const id = (message as { toolCallId?: string }).toolCallId;
if (id) resultsByCall.set(id, String(message.content ?? ""));
}

for (const message of input.messages) {
// Placed with the call they answer, below, rather than wherever they arrived.
if (message.role === "tool") continue;
if (message.role === "user") {
messages.push({ role: "user", content: String(message.content ?? "") });
continue;
Expand All @@ -41,22 +59,19 @@ export function toProviderMessages(
messages.push({ role: "system", content: String(message.content ?? "") });
continue;
}
if (message.role === "tool") {
// Tool results are appended so the model can continue from the completed call.
messages.push({
role: "tool",
tool_call_id: message.toolCallId,
content: String(message.content ?? ""),
});
continue;
}
if (message.role === "assistant") {
const toolCalls = message.toolCalls?.map((call) => ({
id: call.id,
type: "function" as const,
function: {
name: call.function.name,
arguments: call.function.arguments,
/*
* A name is required by the provider and is not always present: read back from the thread
* store these arrive undefined, and a payload carrying `"name": undefined` is rejected
* outright. The call still has to be shown, or the model repeats an action it already
* took, so it keeps its id and is named as something the model can read.
*/
name: call.function?.name ?? "tool",
arguments: call.function?.arguments ?? "{}",
},
}));
messages.push({
Expand All @@ -72,14 +87,22 @@ export function toProviderMessages(
* call, so these go here rather than being appended at the end. A call answered later in the
* history is left alone and its real answer arrives in its own turn.
*/
/*
* Every call this message made, answered, immediately after it.
*
* The real result where there is one, wherever it arrived in the input, and `NO_ANSWER_CAME`
* where there is not. Both cases are the same requirement: a call must be followed by its
* result, and the provider rejects the message outright otherwise.
*/
for (const call of message.toolCalls ?? []) {
if (call.id && !answered.has(call.id)) {
messages.push({
role: "tool",
tool_call_id: call.id,
content: NO_ANSWER_CAME,
});
}
if (!call.id) continue;
messages.push({
role: "tool",
tool_call_id: call.id,
content: answered.has(call.id)
? (resultsByCall.get(call.id) ?? "")
: NO_ANSWER_CAME,
});
}
}
}
Expand Down
19 changes: 19 additions & 0 deletions agent-bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,25 @@ if (!MANAGED_AGENT_TOKEN) {
* chat-completions streaming loop.
*/
const MODEL = process.env.BOT_MODEL ?? "gpt-5.5";
/*
* Refuse a model this file cannot use, rather than discover it one tool call at a time.
*
* `gpt-5.6-*` rejects function tools on `/v1/chat/completions`: "To use function tools, use
* /v1/responses or set reasoning_effort to 'none'." The provider answers with an error, this Bot
* ends the run, and the person sees no reply and no reason. Silence is the worst failure available
* here, and it is what a single mistaken `BOT_MODEL` produced: every tool-using turn stopped dead
* while the Bot looked healthy.
*
* Startup is where a deployment can act on it, which is the same posture as the token check above.
*/
if (/^gpt-5\.[6-9]|^gpt-[6-9]/.test(MODEL)) {
console.error(
`BOT_MODEL=${MODEL} cannot be used by this Bot. It speaks /v1/chat/completions directly, and ` +
"that endpoint refuses function tools for this model, so every tool call would fail with no " +
"reply. Use gpt-5.5, or the framework Bot on port 4201, which speaks the Responses API.",
);
process.exit(1);
}

/**
* Where that model is answered from.
Expand Down
61 changes: 61 additions & 0 deletions agent-bot/tests/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,64 @@ describe("a tool call nothing ever answered", () => {
expect(ids).toEqual(["c1", "c2"]);
});
});

/**
* The history as the durable thread store hands it back.
*
* Read back from a stored thread, a tool result arrives BEFORE the assistant message that made the
* call, and the call's `function.name` is missing. Both are payloads a provider rejects: a tool
* message with no preceding call, and a call with nothing following it. The model answers that with
* silence rather than an error, so a Bot that had just read a document said nothing at all and the
* conversation looked dead.
*
* The exact shape below was copied off a real thread after a Google Drive answer went missing.
*/
describe("a history that arrives out of order", () => {
test("pairs each call with its result, whatever order they arrived in", () => {
const messages = withoutGuidance(
toProviderMessages(
input([
{ id: "1", role: "user", content: "What is in the PRD?" } as Message,
{
id: "2",
role: "tool",
toolCallId: "c1",
content: "the document text",
} as unknown as Message,
{
id: "3",
role: "assistant",
content: "",
toolCalls: [call("c1", "read_file_content")],
} as unknown as Message,
]),
),
);

// Assistant first, then its result. Never a tool message with no call before it.
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "tool"]);
const answer = messages[2] as { tool_call_id?: string; content?: string };
expect(answer.tool_call_id).toBe("c1");
expect(answer.content).toBe("the document text");
});

test("gives a nameless call a name, because the provider requires one", () => {
const messages = withoutGuidance(
toProviderMessages(
input([
{
id: "1",
role: "assistant",
content: "",
toolCalls: [{ id: "c1", type: "function", function: {} }],
} as unknown as Message,
]),
),
);

const assistant = messages[0] as {
tool_calls?: { function: { name: string } }[];
};
expect(assistant.tool_calls?.[0]?.function.name).toBe("tool");
});
});
2 changes: 1 addition & 1 deletion agent-langgraph/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const GOOGLE_BASE_URL =
function defaultModelFor(provider: string): string {
if (provider === "anthropic") return "claude-sonnet-4-5";
if (provider === "google") return "gemini-2.5-flash";
return "gpt-5.6-terra";
return "gpt-5.5";
}

/**
Expand Down
22 changes: 15 additions & 7 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
} from "@copilotkit/react-core/v2";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { readThreadMessages } from "@/lib/copilot/thread-messages";
import { toAgentOptions } from "@/components/channels/composer";
import { ConversationView } from "@/components/channels/conversation-view";
import {
Expand All @@ -22,6 +21,7 @@ import { ConversationProvider } from "@/lib/copilot/conversation";
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
import { stoppedReason } from "@/lib/copilot/stopped-turn";
import { readThreadMessages } from "@/lib/copilot/thread-messages";
import { useSkillCommands } from "@/lib/plugins/skill-commands";
import { newId } from "../../lib/new-id";

Expand Down Expand Up @@ -125,12 +125,20 @@ export function ChannelChat({
let current = true;

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

try {
const stored = await readThreadMessages(
Expand Down
23 changes: 22 additions & 1 deletion app/src/lib/copilot/join-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,30 @@ export async function joinWithin({
// A detach with nothing to detach is not a problem worth reporting, and the wait below is what
// this function actually promises. Swallowing it here keeps that promise on both paths.
}
await finished;
/*
* Bounded, because a detach is a request and not a guarantee.
*
* This was a bare `await finished`, on the reasoning that a detached connect ends promptly. When
* it does not, nothing here ever returns: the caller's `finally` never runs, the gate it opens
* stays shut, and every message typed afterwards waits on it forever. That is silence — the
* message appears in the transcript, no run is ever started, no request reaches the server and
* nothing is logged, which is the hardest failure of all to read.
*
* A connect still running after this grace has outlived its usefulness either way. Going on
* without it risks the overwrite this function exists to prevent; waiting for it risks a
* conversation that never answers again. The first is recoverable and visible. The second is not.
*/
await Promise.race([finished, afterMs(DETACH_GRACE_MS)]);
}

/**
* How long a detached connect is given to finish before the turn goes ahead regardless.
*
* Long enough that an ending connect is waited for, short enough that a stuck one is not the end of
* the conversation.
*/
const DETACH_GRACE_MS = 2_000;

/** A deadline, as a promise. Separate so a test can supply one it controls. */
export function afterMs(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
Expand Down
58 changes: 58 additions & 0 deletions app/tests/join-thread.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,61 @@ describe("joinWithin", () => {
expect(done).toBe(true);
});
});

/**
* A connect that does not end when it is asked to.
*
* `detach` is a request, not a guarantee. The wait after it used to be unbounded, on the reasoning
* that a detached connect ends promptly, and when it does not nothing here ever returns: the
* caller's `finally` never runs, the gate it opens stays shut, and every message typed afterwards
* waits on it forever.
*
* That failure is silent. The message appears in the transcript, no run is started, no request
* reaches the server, and nothing is logged. Two Bots sat mute through a whole gate run before this
* was found, and the only symptom was an answer that never came.
*/
describe("a detached connect that never finishes", () => {
test("does not hold the turn forever", async () => {
let ended = false;
const never = new Promise<void>(() => {});

const settled = joinWithin({
connect: never,
deadline: Promise.resolve(),
detach: async () => {
// Asked, and ignored, which is the case this exists for.
},
}).then(() => {
ended = true;
});

await Promise.race([
settled,
new Promise((resolve) => setTimeout(resolve, 4_000)),
]);

expect(ended).toBe(true);
}, 10_000);

test("still waits for a detached connect that does finish", async () => {
// The behaviour the grace must not throw away: a connect that ends is waited for, so nothing is
// left in flight to overwrite the message.
let finished = false;
let end: () => void = () => {};
const connect = new Promise<void>((resolve) => {
end = () => {
finished = true;
resolve();
};
});

const settled = joinWithin({
connect,
deadline: Promise.resolve(),
detach: async () => end(),
});

await settled;
expect(finished).toBe(true);
});
});
Loading