From b45aa994162cb81695f7a077ac99385e4db9a4ed Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:23:14 +0900 Subject: [PATCH] Mark a callback's tool answer as refused only when it was refused, as the in-process door does `/api/agent-tools/call` put `REFUSAL_MARKER` in front of every throw from `callTool`. `callTool` throws `PluginRefusedError` when a boundary holds, and rethrows a vendor that broke after recording `mcp.call_failed`. A database fault can throw from it too. The marker is what the transcript draws: `chat-transcript.tsx` labels a tool result that starts with it as blocked, and the model reads "Refused." as "not allowed". So for a Bot calling tools back from its own process, a vendor outage ("Refused. fetch failed") or a fault of this deployment's own ("Refused. That tool could not be called.") was shown to the person as a policy refusing, while the audit trail said the call had failed. The in-process door (`grantedTools`) has kept these apart from the start: a refusal is marked, a vendor that failed reads "That tool could not be called: ", and a deployment fault reads "That tool could not be called." The route now gives the same three answers. The refusal's text is unchanged, and the route still passes everything through `withoutStatement` and keeps the deployment-fault shelf. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 +++ server/src/app.ts | 25 ++++++++-- server/tests/agent-callback-token.test.ts | 61 +++++++++++++++++++++++ 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3541db3..1181f15a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ 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. + ## 0.0.12 ### A deployment can broker its Bots into a few hundred apps through Composio diff --git a/server/src/app.ts b/server/src/app.ts index 9598f67da..ea2a1cfd7 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 } from "./plugins/tools"; import { createRoutineRoutes, type RoutineStore } from "./routines/routes"; import type { RoutineRunner } from "./routines/runner"; @@ -1409,13 +1413,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 76be1cb31..602cbb0ed 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. *