diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b589a4fc..39154e82b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The LangGraph Bot says a refused tool call was refused, not that it found nothing + +When the deployment would not run a tool call from the LangGraph Bot — a token it no longer accepts, +or one issued to another Bot — it answered 401 or 403 with a reason and no result, and the Bot told +its model "The tool returned nothing." The model then told the person nothing was found. The Bot now +tells its model the call was refused, with the status and the deployment's reason, the way the +Python LangGraph Bot already does, and the transcript draws it as a refusal. A tool that answered is +passed on exactly as before. + ### The desktop setup's question box waits for a composed character before it asks Enter confirms a character being typed through an input method (Japanese, Chinese, Korean). On the diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index a4b82fa57..a4f3fef92 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -17,6 +17,7 @@ import { toLangChainMessages } from "./history"; import { readReasoningEffort } from "./model-options"; import { apiKeyOrPlaceholder, KEY_VARIABLE, keyIsRequired } from "./model-key"; import { streamRun } from "./stream"; +import { toolAnswer } from "./tool-answer"; /** * The same Bot, on a framework. @@ -267,8 +268,7 @@ async function callTool( */ body: JSON.stringify({ name, args, run }), }); - const body = (await response.json()) as { text?: string }; - return body.text ?? "The tool returned nothing."; + return await toolAnswer(response); } catch (error) { // Reported to the model as a result rather than thrown: the run continues and says what broke. return `That tool could not be called: ${ diff --git a/agent-langgraph/src/tool-answer.ts b/agent-langgraph/src/tool-answer.ts new file mode 100644 index 000000000..1001ea2e3 --- /dev/null +++ b/agent-langgraph/src/tool-answer.ts @@ -0,0 +1,31 @@ +/** + * What the model is told a governed tool call came back with. + * + * Its own module for the reason `history.ts` is: `index.ts` calls `serve()` at module scope, so + * importing it to reach one function binds a port. + * + * A DEPLOYMENT THAT WOULD NOT RUN THE CALL DID NOT RETURN NOTHING. `/api/agent-tools/call` answers a + * callback it cannot verify with 401 or 403 and the reason under `error`, and no `text`. Read as a + * result, that became "The tool returned nothing.", and a model told a search returned nothing tells + * the person nothing was found: the false negative delivered as an answer that `mcp.callback_refused` + * was added to leave a trail for. The trail was fixed; what the model was told was not. + * + * So an answer that is not a success is a refusal, in the words `agent-langgraph-agui` already uses + * for the same response (`tool_runtime.py`), with the deployment's own reason after it when it gave + * one. It leads with the marker the transcript reads, as this Bot's other two refusals in `callTool` + * do. A success is passed on exactly as before. + */ +export async function toolAnswer(response: Response): Promise { + if (!response.ok) { + const refusal = (await response.json().catch(() => null)) as { + error?: unknown; + } | null; + const reason = + typeof refusal?.error === "string" && refusal.error.trim() + ? ` ${refusal.error.trim()}` + : ""; + return `Refused. Tool callback returned HTTP ${response.status}.${reason}`; + } + const body = (await response.json()) as { text?: string }; + return body.text ?? "The tool returned nothing."; +} diff --git a/agent-langgraph/tests/tool-answer.test.ts b/agent-langgraph/tests/tool-answer.test.ts new file mode 100644 index 000000000..02aaebb0b --- /dev/null +++ b/agent-langgraph/tests/tool-answer.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { toolAnswer } from "../src/tool-answer"; + +/** + * What the model is told a governed tool call came back with. + * + * Driven with hand-built responses rather than a deployment, because the thing worth pinning is the + * reading: the three answers `/api/agent-tools/call` can give — a result, a refusal before any grant + * was consulted, and a malformed call — and what each becomes in front of the model. + */ + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +describe("what a tool call came back with", () => { + test("a result reaches the model as the deployment wrote it", async () => { + expect(await toolAnswer(json({ text: "found 3", isError: false }))).toBe( + "found 3", + ); + }); + + test("a refusal the store made is passed on untouched, marker and all", async () => { + const text = + "Refused. No grant lets this Bot use google-drive/search_files."; + expect(await toolAnswer(json({ text, isError: true }))).toBe(text); + }); + + test("a callback the deployment would not accept is a refusal, not an empty result", async () => { + // The shape of a Bot holding a token the deployment no longer accepts: every call answers 401 + // with the reason under `error` and no `text`. Told "the tool returned nothing", the model tells + // the person nothing was found. + const answer = await toolAnswer(json({ error: "Not authorised." }, 401)); + expect(answer).not.toBe("The tool returned nothing."); + expect(answer.startsWith("Refused.")).toBe(true); + expect(answer).toContain("401"); + expect(answer).toContain("Not authorised."); + }); + + test("a token issued to another Bot is a refusal that says so", async () => { + const answer = await toolAnswer( + json({ error: "That token is not for this Bot." }, 403), + ); + expect(answer.startsWith("Refused.")).toBe(true); + expect(answer).toContain("That token is not for this Bot."); + }); + + test("a failure with no readable body still says it failed", async () => { + const answer = await toolAnswer( + new Response("Bad Gateway", { status: 502 }), + ); + expect(answer).not.toBe("The tool returned nothing."); + expect(answer.startsWith("Refused.")).toBe(true); + expect(answer).toContain("502"); + }); +});