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

## Unreleased

### The Python LangGraph Bot tells its model why the deployment refused a tool call

When the deployment would not run a tool call from the Python LangGraph Bot — a token it no longer
accepts, one issued to another Bot, or a malformed call — it answered with the status and a reason
under `error`, and the Bot told its model only "Refused. Tool callback returned HTTP 403." The model
could say a call was refused but not why, and could not correct a call the deployment had named as
malformed. The reason now follows the status, the way the TypeScript LangGraph Bot already passes it
on. A refusal with no readable reason reads exactly as before.
### A tool argument that starts with "Basic" or "Bearer" is no longer refused as a credential

Content inspection read any MCP tool argument whose first word was "basic" or "bearer", in any case,
Expand Down
18 changes: 17 additions & 1 deletion agent-langgraph-agui/src/tool_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ def next_step(state):
return "tools"


def _refusal_reason(response):
"""The deployment's own reason for a callback it would not run, or nothing.

`/api/agent-tools/call` refuses with the reason under `error`, and the model can only tell the
person why, or correct a malformed call, if it is told. Read here rather than inside the
callback's error handler, so a body that is not JSON costs the reason and not the refusal.
"""
try:
body = response.json()
except ValueError:
return ""
error = body.get("error") if isinstance(body, dict) else None
return f" {error.strip()}" if isinstance(error, str) and error.strip() else ""


async def _call_tool(call, context):
async def result():
token = (os.environ.get("AGENT_TOOL_TOKEN") or "").strip()
Expand Down Expand Up @@ -166,7 +181,8 @@ async def result():
)
if not response.is_success:
return (
f"Refused. Tool callback returned HTTP {response.status_code}.",
f"Refused. Tool callback returned HTTP {response.status_code}."
f"{_refusal_reason(response)}",
True,
)
body = response.json()
Expand Down
35 changes: 34 additions & 1 deletion agent-langgraph-agui/tests/test_tool_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,11 @@ def do_POST(self):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
reply = captured.get(
"callback_body", {"text": "deployment result: public marker 43"}
)
self.wfile.write(
json.dumps({"text": "deployment result: public marker 43"}).encode()
reply if isinstance(reply, bytes) else json.dumps(reply).encode()
)
return
captured["model"].append(body)
Expand Down Expand Up @@ -379,6 +382,36 @@ async def test_http_refusal_is_not_reported_as_tool_success(boundary):
assert "deployment result" not in boundary["model"][-1]["messages"][-1]["content"]


@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "reply", "expected"),
[
(
403,
{"error": "That token is not for this Bot."},
"Refused. Tool callback returned HTTP 403. That token is not for this Bot.",
),
(
400,
{"error": " Tool args must be a JSON object. "},
"Refused. Tool callback returned HTTP 400. Tool args must be a JSON object.",
),
(401, {"error": " "}, "Refused. Tool callback returned HTTP 401."),
(502, b"<html>Bad Gateway</html>", "Refused. Tool callback returned HTTP 502."),
],
)
async def test_http_refusal_tells_the_model_the_deployments_reason(
boundary, status, reply, expected
):
# `/api/agent-tools/call` answers a callback it will not run with the reason under `error`. The
# TypeScript LangGraph Bot passes that reason on (`agent-langgraph/src/tool-answer.ts`); without
# it the model knows only a status code, and cannot tell the person why or repair its call.
boundary["callback_status"] = status
boundary["callback_body"] = reply
await run_protocol(run_input(["granted_lookup"], deployment=["granted_lookup"]))
assert boundary["model"][-1]["messages"][-1]["content"] == expected


@pytest.mark.asyncio
async def test_next_request_does_not_inherit_old_tool_offer(boundary):
body = run_input(["computer_navigate"])
Expand Down