diff --git a/CHANGELOG.md b/CHANGELOG.md index a116f2b4d..831690d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/server/src/app.ts b/server/src/app.ts index 78ce54848..44a255fba 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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"; @@ -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, }); } diff --git a/server/tests/agent-callback-token.test.ts b/server/tests/agent-callback-token.test.ts index 64b1190f2..47d01969b 100644 --- a/server/tests/agent-callback-token.test.ts +++ b/server/tests/agent-callback-token.test.ts @@ -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. *