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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions agent-langgraph/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: ${
Expand Down
31 changes: 31 additions & 0 deletions agent-langgraph/src/tool-answer.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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.";
}
58 changes: 58 additions & 0 deletions agent-langgraph/tests/tool-answer.test.ts
Original file line number Diff line number Diff line change
@@ -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("<html>Bad Gateway</html>", { status: 502 }),
);
expect(answer).not.toBe("The tool returned nothing.");
expect(answer.startsWith("Refused.")).toBe(true);
expect(answer).toContain("502");
});
});