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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A vendor that broke no longer reads as a refusal to a Bot running its own loop

When a Bot that calls tools back from its own process, such as the LangGraph Bots, called a tool
whose vendor failed, or hit a fault in this deployment, the answer began "Refused." like a boundary
holding. The conversation drew it as blocked and the model read it as not allowed, while the audit
trail recorded a failed call. Only a refusal is marked now. A vendor that broke reads "That tool could
not be called: …", the way it already did for a Bot running here, and a refusal reads as before.
### A Bot running its own loop is told a vendor's error is the vendor's

A vendor that says no by answering with an error, the way an MCP server refuses a call, reached a Bot
Expand Down
25 changes: 20 additions & 5 deletions server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ import type { OnboardingStore } from "./people/onboarding";
import { MAX_PAGE, type PeopleStore } from "./people/store";
import type { ComposioBroker } from "./plugins/broker";
import { createPluginRoutes } from "./plugins/routes";
import { isDeploymentFault, type PluginStore } from "./plugins/store";
import {
isDeploymentFault,
PluginRefusedError,
type PluginStore,
} from "./plugins/store";
import { REFUSAL_MARKER, vendorAnswer } from "./plugins/tools";
import { createRoutineRoutes, type RoutineStore } from "./routines/routes";
import type { RoutineRunner } from "./routines/runner";
Expand Down Expand Up @@ -1414,13 +1418,24 @@ export function createApp(
* sentence is allowed to contain. Today the two overlap on a query failure and this arm can
* only be reached by something neither recognises — which is exactly the state the last two
* findings in this area were found in, one predicate apart from a leak.
*
* AND ONLY A REFUSAL CARRIES THE MARKER, which is the in-process door's third question. The
* transcript draws an answer that starts with it as a boundary holding, and the model reads
* "Refused." as "not allowed". `callTool` throws `PluginRefusedError` for that, and rethrows
* a vendor that broke after recording `mcp.call_failed`; marking every throw drew a vendor
* outage, or a fault of this deployment's own, as a policy refusing.
*/
if (error instanceof PluginRefusedError) {
return context.json({
text: `${REFUSAL_MARKER} ${withoutStatement(error)}`,
isError: true,
});
}
return context.json({
text: `${REFUSAL_MARKER} ${
text:
error instanceof Error && !isDeploymentFault(error)
? withoutStatement(error)
: "That tool could not be called."
}`,
? `That tool could not be called: ${withoutStatement(error)}`
: "That tool could not be called.",
isError: true,
});
}
Expand Down
61 changes: 61 additions & 0 deletions server/tests/agent-callback-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,67 @@ describe("the tool-call route a callback token guards", () => {
expect(text).toContain("That tool could not be called.");
});

/**
* ONLY A REFUSAL IS MARKED AS ONE, because the marker is what the transcript draws.
*
* `chat-transcript.tsx` labels a tool result that starts with `REFUSAL_MARKER` as blocked, and the
* model reads "Refused." as "not allowed". `callTool` throws `PluginRefusedError` for a boundary
* holding, and rethrows a vendor that broke after recording `mcp.call_failed`. The in-process door
* keeps the two apart — "one means 'not allowed', the other means 'it broke'" — and this route
* put the marker in front of every throw, so on a Bot running its own loop a vendor outage and a
* database fault were both drawn as a policy refusal. Asked of both doors with the same store.
*/
test("a throw is marked as a refusal only when it is one, the way the in-process door marks it", async () => {
const { grantedTools, REFUSAL_MARKER } = await import(
"../src/plugins/tools"
);
const throws: [string, unknown][] = [
["vendor failure", new Error("fetch failed")],
["deployment fault", queryFailure()],
[
"refusal",
new PluginRefusedError(
"No Bot holds linear/LINEAR_CREATE_ISSUE, so nothing was called.",
null,
),
],
];

const seen: string[] = [];
for (const [kind, thrown] of throws) {
const callback = await toolResult(thrown);
const [tool] = await grantedTools({
store: {
callTool: async () => {
throw thrown;
},
listForAgent: async () => ({
tools: [
{
toolName: "mcp__linear__LINEAR_CREATE_ISSUE",
ref: "linear/LINEAR_CREATE_ISSUE",
description: "Create an issue.",
inputSchema: { type: "object" },
},
],
}),
} as unknown as PluginStore,
botId: "knowledge",
actorId: "usr_7",
});
const inProcess = await tool?.execute({});
seen.push(
`${kind}: marked ${callback.startsWith(REFUSAL_MARKER)}, same as in-process ${callback === inProcess}`,
);
}

expect(seen).toEqual([
"vendor failure: marked false, same as in-process true",
"deployment fault: marked false, same as in-process true",
"refusal: marked true, same as in-process true",
]);
});

/**
* AND THE REFUSAL STILL SPEAKS, because a guard that silences everything is not the fix.
*
Expand Down