From 462c1c14dde090ce7596905bf556e3894fc30abe Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:04:49 -0700 Subject: [PATCH] fix(tools): tool_call bridge treats blank arguments as no arguments Some OpenAI-compatible gateways emit `arguments: ""` (or whitespace) for a tool whose schema has no required parameters. On the tool_search bridge path (`normalize_tool_call_entries`) that string went straight into json.loads and every attempt failed with "arguments is not valid JSON: Expecting value ...", so the model retried the same call forever and the tool never ran (#83937). Treat a blank string like the already-accepted `None`: it means {}. The outer-loop already does this for the top-level tool_call arguments string (turn_tool_validation), so the bridge was the only surface left rejecting the shape; a tool with required params still gets the proper "missing required" hint from validate_deferred_call_args, and malformed non-blank JSON keeps failing closed (#61784 intent intact). Co-authored-by: webtecnica --- tests/tools/test_tool_search.py | 11 +++++++++++ tools/tool_search_validation.py | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index cd77ed8149..e44da62f14 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -471,6 +471,17 @@ class TestBridgeDispatch: assert err is not None assert "bridge tool" in err.lower() + @pytest.mark.parametrize("raw_args", ["", " \n", None]) + def test_resolve_underlying_call_treats_blank_arguments_as_no_arguments(self, raw_args): + """An OpenAI-compatible gateway emitting ``arguments: ""`` for a parameterless deferred tool + must execute with {} instead of looping on a JSON parse error (#83937); malformed + non-blank arguments still fail closed.""" + from tools.tool_search import resolve_underlying_call + name, args, err = resolve_underlying_call({"calls": [{"name": "todo_list", "arguments": raw_args}]}) + assert (name, args, err) == ("todo_list", {}, None) + _, _, err = resolve_underlying_call({"calls": [{"name": "todo_list", "arguments": '{"todos": ['}]}) + assert err and "not valid JSON" in err + # --------------------------------------------------------------------------- # End-to-end via the real handle_function_call (smoke test). diff --git a/tools/tool_search_validation.py b/tools/tool_search_validation.py index a0cc28c67d..fb62f87977 100644 --- a/tools/tool_search_validation.py +++ b/tools/tool_search_validation.py @@ -173,7 +173,11 @@ def normalize_tool_call_entries(args: Dict[str, Any]) -> Tuple[List[Dict[str, An if name in BRIDGE_TOOL_NAMES: return [], f"tool_call cannot invoke '{name}' (it is itself a bridge tool)" raw_args = raw.get("arguments") - if raw_args is None: + if raw_args is None or (isinstance(raw_args, str) and not raw_args.strip()): + # "" / whitespace is how some OpenAI-compatible gateways spell "no arguments" for a + # parameterless tool (#83937); the loop already treats an empty outer arguments string + # as {} (turn_tool_validation), and a missing required param still surfaces below via + # validate_deferred_call_args instead of an opaque JSON parse error. raw_args = {} if isinstance(raw_args, str): try: