diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a93b8e0..715388e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/agent-langgraph-agui/src/tool_runtime.py b/agent-langgraph-agui/src/tool_runtime.py index 4e52ffb8d..c6d5311e2 100644 --- a/agent-langgraph-agui/src/tool_runtime.py +++ b/agent-langgraph-agui/src/tool_runtime.py @@ -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() @@ -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() diff --git a/agent-langgraph-agui/tests/test_tool_protocol.py b/agent-langgraph-agui/tests/test_tool_protocol.py index 627aedb66..70ffb00bd 100644 --- a/agent-langgraph-agui/tests/test_tool_protocol.py +++ b/agent-langgraph-agui/tests/test_tool_protocol.py @@ -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) @@ -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"Bad Gateway", "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"])