Skip to content

Commit 0ad74c2

Browse files
kevin9327claudedavidmckayv
authored
Name a vendor's error as the vendor's to a Bot running its own loop, as the in-process door does (#569)
A vendor that says no by answering `{ isError: true }` with a sentence, the way an MCP server refuses, reaches a Bot's model through one of two doors to the same `callTool`. The in-process door (`grantedTools`) has named that sentence as the vendor's since #97, because handing it over as content cost a diagnosis: Google's "The caller does not have permission" read as a result, and the model told the person it had no access to their Drive. `/api/agent-tools/call`, the door a framework Bot calls back through, answered with the bare text. Neither framework Bot words it on the way through: the LangGraph Bot passes an `isError` answer on untouched, and the Python LangGraph Bot reads only `text`. So on those Bots the model was still handed the vendor's refusal as an ordinary result. The wording now lives in one helper, `vendorAnswer`, and both doors use it. A result that is not an error is unchanged, and so are this deployment's own refusals, which keep `REFUSAL_MARKER` and never reach this helper. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: David McKay <david@copilotkit.ai>
1 parent 775a9e5 commit 0ad74c2

4 files changed

Lines changed: 141 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### A Bot running its own loop is told a vendor's error is the vendor's
12+
13+
A vendor that says no by answering with an error, the way an MCP server refuses a call, reached a Bot
14+
running here as "The vendor reported an error: …", and reached a Bot calling tools back from its own
15+
process, such as the LangGraph Bots, as the bare sentence. Those Bots pass the answer on as they
16+
receive it, so their model read something like Google's "The caller does not have permission" as an
17+
ordinary result, and could tell the person they had no access rather than that the vendor had refused.
18+
Both kinds of Bot are now told the same thing. A result that is not an error, and this deployment's
19+
own refusals, read as before.
1120
### A long Composio result or failure is cut between characters, not through an emoji
1221

1322
A Composio action's answer over 20,000 characters, and a failure sentence as long, were cut by UTF-16

‎server/src/app.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ import { MAX_PAGE, type PeopleStore } from "./people/store";
6060
import type { ComposioBroker } from "./plugins/broker";
6161
import { createPluginRoutes } from "./plugins/routes";
6262
import { isDeploymentFault, type PluginStore } from "./plugins/store";
63-
import { REFUSAL_MARKER } from "./plugins/tools";
63+
import { REFUSAL_MARKER, vendorAnswer } from "./plugins/tools";
6464
import { createRoutineRoutes, type RoutineStore } from "./routines/routes";
6565
import type { RoutineRunner } from "./routines/runner";
6666
import type { IntentRouter } from "./routing/classify";
@@ -1382,7 +1382,12 @@ export function createApp(
13821382
actorId: verdict.actorId,
13831383
...(verdict.initiator ? { initiator: verdict.initiator } : {}),
13841384
});
1385-
return context.json({ text: result.text, isError: result.isError });
1385+
// Worded by the helper the in-process door uses, so a framework Bot's model reads a vendor's
1386+
// error as the vendor's and not as a result. Neither Bot words it on its way through.
1387+
return context.json({
1388+
text: vendorAnswer(result),
1389+
isError: result.isError,
1390+
});
13861391
} catch (error) {
13871392
/*
13881393
* A refusal is an answer, not a failure: the Bot says what was blocked and carries on. The

‎server/src/plugins/tools.ts‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,30 @@ import {
3232
*/
3333
export const REFUSAL_MARKER = "Refused.";
3434

35+
/**
36+
* What a model is told a vendor answered: its result as written, or its error named as one.
37+
*
38+
* `isError` used to be dropped, and it cost a diagnosis. Google refused the Drive MCP server with
39+
* `isError: true` and the text "The caller does not have permission"; the model received that as an
40+
* ordinary result, believed it, and told the person it had no access to their Drive — which read as
41+
* the Bot being confused rather than as the vendor refusing.
42+
*
43+
* The prefix is the vendor's, and says so. It is deliberately NOT `REFUSAL_MARKER`: that one means
44+
* this deployment declined, and the transcript draws it as a boundary holding. A vendor saying no is
45+
* a different fact with a different fix, and collapsing the two would make a misconfigured connector
46+
* look like a policy working correctly.
47+
*
48+
* One function for both doors to one store — {@link grantedTools} for a Bot running here, and
49+
* `/api/agent-tools/call` for a Bot running its own loop — because the second answered with the bare
50+
* text, and neither framework Bot words an `isError` answer on its way through. Which door a Bot
51+
* arrives at is a deployment topology decision, not a decision about what its model is told.
52+
*/
53+
export function vendorAnswer(result: { text: string; isError: boolean }) {
54+
return result.isError
55+
? `The vendor reported an error: ${result.text}`
56+
: result.text;
57+
}
58+
3559
export type GrantedTool = {
3660
name: string;
3761
description: string;
@@ -187,22 +211,7 @@ export async function grantedTools(options: {
187211
actorId,
188212
...(initiator ? { initiator } : {}),
189213
});
190-
/*
191-
* A vendor's error is named as one, not handed over as content.
192-
*
193-
* `isError` used to be dropped here, and it cost a diagnosis. Google refused the Drive MCP
194-
* server with `isError: true` and the text "The caller does not have permission"; the model
195-
* received that as an ordinary result, believed it, and told the person it had no access to
196-
* their Drive — which read as the Bot being confused rather than as the vendor refusing.
197-
*
198-
* The prefix is the vendor's, and says so. It is deliberately NOT `REFUSAL_MARKER`: that one
199-
* means this deployment declined, and the transcript draws it as a boundary holding. A vendor
200-
* saying no is a different fact with a different fix, and collapsing the two would make a
201-
* misconfigured connector look like a policy working correctly.
202-
*/
203-
return result.isError
204-
? `The vendor reported an error: ${result.text}`
205-
: result.text;
214+
return vendorAnswer(result);
206215
} catch (error) {
207216
if (error instanceof PluginRefusedError) {
208217
return `${REFUSAL_MARKER} ${error.message}`;

‎server/tests/agent-callback-token.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,4 +496,104 @@ describe("the tool-call route a callback token guards", () => {
496496
await toolResult(new Error("The caller does not have permission.")),
497497
).toContain("The caller does not have permission.");
498498
});
499+
500+
/**
501+
* A VENDOR THAT ANSWERED WITH AN ERROR, rather than one that threw, which is how an MCP server says
502+
* no: `{ isError: true }` and a sentence, resolved and not thrown.
503+
*
504+
* The in-process door names that sentence as the vendor's (`plugins/tools.ts`), because handing it
505+
* over as content already cost a diagnosis: Google's "The caller does not have permission" read as
506+
* a result, and the model told the person it had no access to their Drive. Neither framework Bot
507+
* words it on its way through — the LangGraph Bot passes an `isError` answer on untouched, and
508+
* the Python one reads only `text` — so what this route writes is what the model reads.
509+
*/
510+
function storeAnswering(result: { text: string; isError: boolean }) {
511+
return {
512+
callTool: async () => ({ ...result, truncated: false }),
513+
listForAgent: async () => ({
514+
tools: [
515+
{
516+
toolName: "mcp__linear__LINEAR_CREATE_ISSUE",
517+
ref: "linear/LINEAR_CREATE_ISSUE",
518+
description: "Create an issue.",
519+
inputSchema: { type: "object" },
520+
},
521+
],
522+
}),
523+
} as unknown as PluginStore;
524+
}
525+
526+
/** Both doors' answers to one call against the same store: the callback route's, and the in-process one's. */
527+
async function bothDoors(store: PluginStore) {
528+
const response = await createApp(
529+
config,
530+
undefined,
531+
undefined,
532+
undefined,
533+
undefined,
534+
undefined,
535+
undefined,
536+
undefined,
537+
undefined,
538+
undefined,
539+
undefined,
540+
undefined,
541+
undefined,
542+
undefined,
543+
store,
544+
).request("http://openbot.local/api/agent-tools/call", {
545+
method: "POST",
546+
headers: {
547+
"content-type": "application/json",
548+
"x-openbot-agent-token": DEPLOYMENT_TOKEN,
549+
},
550+
body: JSON.stringify({
551+
name: "mcp__linear__LINEAR_CREATE_ISSUE",
552+
args: {},
553+
run: mintRunAssertion(
554+
{ botId: "knowledge", actorId: "usr_7", runId: "run_1" },
555+
config.keyEncryptionKey,
556+
),
557+
}),
558+
});
559+
expect(response.status).toBe(200);
560+
const callback = (await response.json()) as {
561+
text: string;
562+
isError: boolean;
563+
};
564+
565+
const { grantedTools } = await import("../src/plugins/tools");
566+
const [tool] = await grantedTools({
567+
store,
568+
botId: "knowledge",
569+
actorId: "usr_7",
570+
});
571+
const inProcess = await tool?.execute({});
572+
return { callback, inProcess };
573+
}
574+
575+
test("a vendor's error answer is named as the vendor's, the way the in-process door names it", async () => {
576+
const { callback, inProcess } = await bothDoors(
577+
storeAnswering({
578+
text: "The caller does not have permission.",
579+
isError: true,
580+
}),
581+
);
582+
583+
expect(callback.isError).toBe(true);
584+
// One store, one answer: which door a Bot comes through is topology, not what its model is told.
585+
expect(callback.text).toBe(inProcess);
586+
expect(callback.text).toBe(
587+
"The vendor reported an error: The caller does not have permission.",
588+
);
589+
});
590+
591+
test("a vendor's result that is not an error reaches the model as the vendor wrote it", async () => {
592+
const { callback, inProcess } = await bothDoors(
593+
storeAnswering({ text: "Created LIN-42.", isError: false }),
594+
);
595+
596+
expect(callback).toEqual({ text: "Created LIN-42.", isError: false });
597+
expect(callback.text).toBe(inProcess);
598+
});
499599
});

0 commit comments

Comments
 (0)