Skip to content

Commit bef7cd4

Browse files
authored
Say a connector is not granted, instead of browsing to the vendor (#169)
* Say a connector is not granted, instead of browsing to the vendor 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, the built-in Bot opened drive.google.com, met Google's sign-in page, and asked the person to sign in to an account this deployment had already connected. The connector existed. The Bot simply was not on it, and nothing said so. That is the half of the same problem the earlier fix did not reach: that one covered a Bot holding SOME of a vendor's tools and not the one it needed. A Bot holding none got an empty string. Every Bot is now told which vendors this deployment connects to, whether or not it holds them, and what to do about the ones it does not: say plainly that it has not been granted it, name it, and say an administrator can grant it on that connector. Not the browser, which for a vendor with a connector is not a second route to the same place: the connector exists so the vendor is reached as the person asking, and the container's browser is signed in as nobody. Read per request rather than held, because a connector added a minute ago has to count, and a store that cannot answer is treated as no connectors: a Bot that cannot be told loses a sentence, not a run. The computer card no longer reserves a screen-sized frame for a browser that has opened nothing. That put a placeholder the height of a browser window into the middle of a conversation, above an answer that never involved the browser at all. Nothing is loading there and nothing is coming, so there is no layout jump to protect against and no reason to take the room. Driven in Chrome, the exact case: General Assistant, no Drive grant, asked to open drive.google.com and name the first file. It opens nothing and answers "the Google Drive connector has not been granted to me in this deployment. An administrator can enable it on that connector." * Catch the changelog up with the rest of the merge Seven of the nine changes landing together carried no entry: the model refresh, this PR's own two, the declined-handover fix for the Bot in the box, the takeover flag, the always-available wheel, the lost first message, and the snapshot store guard. Written here because this one lands last, so the entry is complete rather than seven near-duplicates racing each other for the same section. Credited where the work was somebody else's.
1 parent f74d116 commit bef7cd4

6 files changed

Lines changed: 200 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,52 @@ Sessions survive and nobody signs in again.
223223
next migration.** Repair it with
224224
`update drizzle.__drizzle_migrations set created_at = 1787359000000 where created_at = 1787444747113;`
225225
or start from a fresh database, where migrations all run in one pass and ordering cannot bite.
226+
- **Every Bot ran a model two generations old, and it was costing tool calls.** The example package
227+
shipped `gpt-4.1` as the default for every built-in Bot. Asked to open a page behind a sign-in,
228+
those Bots answered "would you like me to prompt you to sign in?" and called nothing, three times
229+
out of three, while the prompt forbids that sentence in as many words. On `gpt-5.6-terra` the same
230+
question produces the tool call first try. The default is now `gpt-5.6-terra` across the package,
231+
the compose services and both example Bots, and the Responses API is inferred from the model rather
232+
than left to a separate switch, because `gpt-5.6-*` rejects function tools on chat completions and
233+
a deployment that set the model without knowing that got a Bot which started, looked healthy, and
234+
failed on its first tool call. It is a default, not a commitment: `BOT_MODEL` and the package's
235+
`model.yaml` still decide. `agent-bot` stays on `gpt-5.5` on purpose, since the only ways to 5.6 on
236+
the endpoint it writes by hand are a streaming rewrite or turning reasoning off, and it is the Bot
237+
whose job includes deciding when to ask a person for help.
238+
- **A Bot browsed to a vendor this deployment already connects to.** A Bot holding no grants was told
239+
nothing about connectors at all, so it treated a connected vendor as an ordinary website: asked
240+
about Google Drive it opened `drive.google.com`, met a sign-in page, and asked the person to sign
241+
in to an account the deployment had already connected. Every Bot is now told which vendors exist
242+
here, held or not, and says plainly which one it has not been granted rather than reaching for the
243+
browser.
244+
- **A conversation was destroyed by a declined take-the-wheel.** A Bot that asks for help with a
245+
sign-in and never gets it left a tool call nothing ever answered, and every later turn in that
246+
thread failed at the provider. This was fixed once for the framework Bot and not for the Bot in the
247+
box, which is the one behind the Browser Bot, so it went on happening where most people would meet
248+
it. Both now answer their own unanswered calls with the truth rather than a fake success.
249+
- **A Bot refused because a person had the wheel was told its refs were stale.** The computer flags a
250+
takeover, the surface branches on that flag, and the flag did not survive the server, so a Bot was
251+
sent back round the same action against the person who had just taken the browser. Reported and
252+
fixed by @beardthelion.
253+
- **A person could not take the wheel unless the Bot offered it.** The button appeared only after a
254+
Bot called for help, so the control a person needs depended on the Bot getting one instruction
255+
right, and when it did not there was nothing to press. It is there whenever the Bot is driving now.
256+
The Bot asking for help is still its own row, with its reason.
257+
- **The first message of a new channel could be lost.** A new channel's thread does not exist until
258+
its first run, so the join that restores history had nothing to settle against; the message was sent
259+
anyway after a deadline, while that join was still in flight, and the join finishing replaced it
260+
with the thread's messages, which were none. The deadline now ends the join and waits for it, so
261+
nothing is left in flight to overwrite anything. The transcript also says it is loading rather than
262+
showing an empty conversation, and the thinking line is visible for the first time: a CSS rule
263+
blanked the colour a gradient was built from, so the glyphs were painted with nothing. Reported and
264+
fixed by @zopeVaibhav.
265+
- **The in-memory snapshot store disagreed with the table.** The database only ever moves a snapshot
266+
forward; the in-memory one, which is what a test reaches for when it has no database, took whatever
267+
arrived last. A test could therefore prove a boundary property that is false in a deployment.
268+
Reported by @beardthelion, fixed by @NathanTarbert.
269+
- **A computer that had opened nothing still reserved a browser-sized frame.** That put a placeholder
270+
the height of a browser window into the middle of a conversation, above an answer that never
271+
involved the browser.
226272
- **A Bot named after a deployment route was served without its guard.** The computer router steps
227273
aside for `/policy` and `/fleet`, which are its own paths and not about a Bot, because Hono matches
228274
`/*` against zero segments and a single-segment path arrives as a Bot id. It stepped aside on the

‎app/src/components/computer/computer-view.tsx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,22 @@ export function ComputerView({
171171
return () => window.removeEventListener("keydown", onKey);
172172
}, [expanded]);
173173

174-
// Sized from the ratio, never from the payload, so the frame is identical in all three states.
175-
const frameStyle = { aspectRatio, minWidth, minHeight };
176-
177174
// Always render the card frame; help/secret controls live below the conditional picture.
178175
const blankBrowser = shot ? isBlankBrowser(shot) : false;
176+
177+
/*
178+
* Sized from the ratio, never from the payload, so the frame is identical while a screen is
179+
* loading and once it arrives.
180+
*
181+
* A browser that has opened nothing is the exception. Reserving a screen-sized frame for it put a
182+
* placeholder the height of a browser window into the middle of a conversation, above an answer
183+
* that never involved the browser at all: a Bot asked about Google Drive rendered a full-size
184+
* empty panel saying it had not opened a page. Nothing is loading there and nothing is coming, so
185+
* there is no layout jump to protect against and no reason to take the room.
186+
*/
187+
const frameStyle = blankBrowser
188+
? { minWidth }
189+
: { aspectRatio, minWidth, minHeight };
179190
/** Blank browser placeholders should not be opened as readable screens. */
180191
const showScreen = shot !== null && !blankBrowser;
181192

@@ -206,6 +217,8 @@ export function ComputerView({
206217
{blankBrowser ? (
207218
<ComputerPlaceholder className="absolute inset-0 h-full w-full" />
208219
) : null}
220+
{/* The blank state is a line of text, so it needs its own height rather than the frame's. */}
221+
{blankBrowser ? <span className="block py-6" /> : null}
209222

210223
{showScreen ? null : (
211224
<span

‎server/src/copilot.ts‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import {
1111
COMPUTER_GUIDANCE,
1212
PROVENANCE_GUIDANCE,
1313
} from "../../shared/bot-prompt";
14-
import { grantedToolGuidance } from "./plugins/tools";
1514
import type { AgentActor } from "./agents/profile-types";
1615
import type { StallGuard } from "./channels/stall-guard";
1716
import type { DeploymentConfig } from "./config";
1817
import type { GrantedTool } from "./plugins/tools";
18+
import { grantedToolGuidance } from "./plugins/tools";
1919

2020
/**
2121
* The CopilotKit runtime, always in Intelligence mode.
@@ -197,6 +197,13 @@ export function builtInAgentConfiguration(
197197
* be promising something that does not exist.
198198
*/
199199
computerGuidance?: string,
200+
/**
201+
* Vendors this deployment connects to, whether or not this Bot holds any of their tools.
202+
*
203+
* A Bot holding nothing was told nothing, so it treated a connected vendor as an ordinary website
204+
* and browsed to it. See `grantedToolGuidance`.
205+
*/
206+
connectedVendors: readonly string[] = [],
200207
): BuiltInAgentConfiguration {
201208
if (!apiKey) {
202209
return {
@@ -229,7 +236,9 @@ export function builtInAgentConfiguration(
229236
* it says comes from its own knowledge, and saying so is the only honest move available.
230237
*/
231238
PROVENANCE_GUIDANCE,
232-
...(grantedToolGuidance(tools) ? [grantedToolGuidance(tools)] : []),
239+
...(grantedToolGuidance(tools, connectedVendors)
240+
? [grantedToolGuidance(tools, connectedVendors)]
241+
: []),
233242
...(computerGuidance ? [computerGuidance] : []),
234243
].join("\n\n"),
235244
apiKey,
@@ -271,7 +280,13 @@ export async function buildAgents(
271280
signRun?: SignRun,
272281
/** What every built-in Bot is told about the computer. Absent means this deployment has none. */
273282
computerGuidance?: string,
283+
/**
284+
* Which vendors this deployment connects to. Asked once per build rather than per Bot, because it
285+
* is a fact about the deployment; what differs per Bot is which of them it holds.
286+
*/
287+
loadVendors: () => Promise<readonly string[]> = async () => [],
274288
): Promise<Record<string, AbstractAgent>> {
289+
const vendors = await loadVendors().catch(() => [] as readonly string[]);
275290
return Object.fromEntries(
276291
await Promise.all(
277292
agents.map(async (agent) => [
@@ -284,6 +299,7 @@ export async function buildAgents(
284299
loadTools,
285300
signRun,
286301
computerGuidance,
302+
vendors,
287303
),
288304
]),
289305
),
@@ -298,6 +314,7 @@ async function buildAgent(
298314
loadTools: LoadToolsForBot,
299315
signRun?: SignRun,
300316
computerGuidance?: string,
317+
connectedVendors: readonly string[] = [],
301318
): Promise<AbstractAgent> {
302319
if (agent.type === "built_in") {
303320
return new BuiltInAgent(
@@ -307,6 +324,7 @@ async function buildAgent(
307324
apiKey,
308325
await loadTools(agent.id),
309326
computerGuidance,
327+
connectedVendors,
310328
),
311329
);
312330
}
@@ -318,6 +336,7 @@ async function buildAgent(
318336
stallGuard,
319337
await loadTools(agent.id),
320338
signRun,
339+
connectedVendors,
321340
);
322341
}
323342

@@ -345,6 +364,8 @@ function remoteAgentWithStandingRole(
345364
*/
346365
tools: GrantedTool[] = [],
347366
signRun?: SignRun,
367+
/** As for the built-in path: what this deployment connects to, held or not. */
368+
connectedVendors: readonly string[] = [],
348369
) {
349370
const remote = new HttpAgent({
350371
url: agent.endpoint,
@@ -368,7 +389,7 @@ function remoteAgentWithStandingRole(
368389
* prompt — a page about the browser that mentions connectors nowhere. That is the Bot that browsed
369390
* to drive.google.com holding four Drive tools.
370391
*/
371-
const holdings = grantedToolGuidance(tools);
392+
const holdings = grantedToolGuidance(tools, connectedVendors);
372393
const holdingsMessage = holdings
373394
? {
374395
id: `granted-tools:${agent.id}`,
@@ -467,6 +488,7 @@ export async function resolveRuntimeAgents(
467488
loadTools?: LoadToolsForBot,
468489
signRun?: SignRun,
469490
computerGuidance?: string,
491+
loadVendors?: () => Promise<readonly string[]>,
470492
): Promise<Record<string, AbstractAgent>> {
471493
const registered = await loadAgents();
472494
if (registered.length === 0) {
@@ -486,6 +508,7 @@ export async function resolveRuntimeAgents(
486508
loadTools,
487509
signRun,
488510
computerGuidance,
511+
loadVendors,
489512
);
490513
}
491514

@@ -533,6 +556,8 @@ export function createRequestAgents(
533556
signRunForActor?: (actorId: string) => SignRun,
534557
/** What every built-in Bot is told about the computer. Absent means this deployment has none. */
535558
computerGuidance?: string,
559+
/** Which vendors this deployment connects to, held by a Bot or not. Absent means none. */
560+
loadVendors?: () => Promise<readonly string[]>,
536561
) {
537562
return async ({ request }: { request: Request }) => {
538563
const actor = await identifyActor(request);
@@ -544,6 +569,7 @@ export function createRequestAgents(
544569
loadToolsForActor?.(actor.id),
545570
signRunForActor?.(actor.id),
546571
computerGuidance,
572+
loadVendors,
547573
);
548574
};
549575
}
@@ -571,6 +597,7 @@ export function mountCopilotRuntime(
571597
loadToolsForActor?: (actorId: string) => LoadToolsForBot,
572598
signRunForActor?: (actorId: string) => SignRun,
573599
basePath = "/api/copilotkit",
600+
loadVendors?: () => Promise<readonly string[]>,
574601
) {
575602
const { intelligence } = config.runtime;
576603

@@ -609,6 +636,7 @@ export function mountCopilotRuntime(
609636
* as impossible. Absent computer, absent guidance: a Bot is not told about hands it has not got.
610637
*/
611638
config.computer ? COMPUTER_GUIDANCE : undefined,
639+
loadVendors,
612640
) as never,
613641
});
614642

‎server/src/index.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { createIntentRouter } from "./routing/classify";
2-
import { createModelCompleter } from "./routing/model";
31
import { serve } from "bun";
42
import { mintRunAssertion } from "./agents/callback-token";
53
import { createAgentProfileStore } from "./agents/profile-store";
@@ -48,6 +46,8 @@ import { createDatabase } from "./db/client";
4846
import { createPeopleStore } from "./people/store";
4947
import { createPluginStore } from "./plugins/store";
5048
import { grantedTools } from "./plugins/tools";
49+
import { createIntentRouter } from "./routing/classify";
50+
import { createModelCompleter } from "./routing/model";
5151
import {
5252
createPackageStatusReader,
5353
loadTenantPackage,
@@ -416,6 +416,25 @@ const app = createApp(
416416
*/
417417
(actorId) => (botId, runId) =>
418418
mintRunAssertion({ botId, actorId, runId }, config.keyEncryptionKey),
419+
undefined,
420+
/*
421+
* Which vendors this deployment connects to, held by a Bot or not.
422+
*
423+
* A Bot holding no grants used to be told nothing about connectors at all, so it treated a
424+
* connected vendor as an ordinary website and browsed to it: a Bot with no Drive grant opened
425+
* Google's sign-in page and asked a person to sign in to an account the deployment had already
426+
* connected. Naming them lets it say which one it has not been granted instead.
427+
*
428+
* Read per request rather than held, because a connector added a minute ago has to count, and
429+
* failing is the same as having none: a Bot that cannot be told loses a sentence, not a run.
430+
*/
431+
async () => {
432+
try {
433+
return (await pluginStore.listServers()).map((server) => server.id);
434+
} catch {
435+
return [];
436+
}
437+
},
419438
),
420439
// The only path to an acting call.
421440
computerGateway,

‎server/src/plugins/tools.ts‎

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,19 @@ export function parametersFor(inputSchema: Record<string, unknown>): z.ZodType {
6666
*
6767
* Empty when the Bot holds nothing, so a deployment with no connectors says nothing about them.
6868
*/
69-
export function grantedToolGuidance(tools: GrantedTool[]): string {
70-
if (tools.length === 0) return "";
69+
export function grantedToolGuidance(
70+
tools: GrantedTool[],
71+
/**
72+
* Systems this deployment connects to that this Bot holds nothing for.
73+
*
74+
* Without these a Bot holding no grants is told nothing at all, so it treats a connected vendor as
75+
* an ordinary website and browses to it. That is how a Bot with no Drive grant ended up on Google's
76+
* sign-in page asking a person to sign in to an account the deployment had already connected: the
77+
* connector existed, the Bot simply was not on it, and nothing said so.
78+
*/
79+
connectedButNotHeld: readonly string[] = [],
80+
): string {
81+
if (tools.length === 0 && connectedButNotHeld.length === 0) return "";
7182

7283
const bySystem = new Map<string, string[]>();
7384
for (const tool of tools) {
@@ -78,19 +89,51 @@ export function grantedToolGuidance(tools: GrantedTool[]): string {
7889
bySystem.set(system, [...(bySystem.get(system) ?? []), rest]);
7990
}
8091

92+
const held = [...bySystem.keys()];
93+
const missing = connectedButNotHeld.filter(
94+
(system) => !held.includes(system),
95+
);
96+
8197
return [
82-
"You can reach these systems directly, as the person asking, with their own access:",
98+
...(tools.length > 0
99+
? [
100+
"You can reach these systems directly, as the person asking, with their own access:",
101+
]
102+
: []),
83103
...[...bySystem.entries()].map(
84104
([system, names]) => `- ${system}: ${names.join(", ")}`,
85105
),
86-
"Use them for anything about those systems. Do NOT browse to one of their websites instead: your",
87-
"browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall",
88-
"that connecting an account has already solved.",
89-
"If one of these systems is involved and no tool above covers the part you need, that is a",
90-
"missing grant and not something to work around. Say so plainly, name the capability you would",
91-
"need, and say an administrator can grant it on that connector. Do not reach for the browser, do",
92-
"not ask the person to sign in, and do not ask them to fetch it for you: they already have the",
93-
"access, and the thing that is missing is yours, not theirs.",
106+
...(tools.length > 0
107+
? [
108+
"Use them for anything about those systems. Do NOT browse to one of their websites instead: your",
109+
"browser is signed in as nobody, so it sees less than these tools do and will meet a sign-in wall",
110+
"that connecting an account has already solved.",
111+
"If one of these systems is involved and no tool above covers the part you need, that is a",
112+
"missing grant and not something to work around. Say so plainly, name the capability you would",
113+
"need, and say an administrator can grant it on that connector. Do not reach for the browser, do",
114+
"not ask the person to sign in, and do not ask them to fetch it for you: they already have the",
115+
"access, and the thing that is missing is yours, not theirs.",
116+
]
117+
: []),
118+
/*
119+
* The vendors this deployment connects to and this Bot does not hold.
120+
*
121+
* Named so the Bot can say which one, because "I have not been granted it" is only actionable if
122+
* the person is told what "it" is. The browser is refused for these by the same reasoning as
123+
* above and for a sharper reason: a connector exists precisely so the vendor is reached as the
124+
* person asking, and the container's browser is signed in as nobody, so browsing there abandons
125+
* the per-person path and lands on a login wall by construction.
126+
*/
127+
...(missing.length > 0
128+
? [
129+
...(tools.length > 0 ? [""] : []),
130+
`This deployment also connects to: ${missing.join(", ")}. You hold none of their tools.`,
131+
"If a question needs one of them, say plainly that you have not been granted it and that an",
132+
"administrator can grant it on that connector. Do NOT browse to its website: that is not the",
133+
"same thing, your browser is signed in as nobody, and it will meet a sign-in wall that the",
134+
"connector exists to avoid. Do not ask the person to sign in there either.",
135+
]
136+
: []),
94137
].join("\n");
95138
}
96139

0 commit comments

Comments
 (0)