Improve tool call validation strictness (#4190)

* Improve tool call validation strictness

Reject near-miss tool names without executing suggested tools. Require object-shaped tool parameters while preserving only lossless JSON wire-shape normalization.

* Tighten tool call argument validation

* Simplify tool argument validation tests

* Improve tool name suggestions

* Simplify tool suggestion helpers

* Limit tool suggestions to canonical matches

* Allow repair only for tool history replay

* Clarify non-object tool argument errors

* Inline replay tool argument normalization

* Track only successful tool executions

* Reject JSON null tool arguments
This commit is contained in:
chengyongru
2026-06-09 14:50:40 +08:00
committed by GitHub
parent f3eb2aa08b
commit 0a396aa6e2
17 changed files with 769 additions and 142 deletions
+12 -29
View File
@@ -17,10 +17,15 @@ from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import json_repair
from loguru import logger
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ToolCallRequest,
parse_tool_arguments,
tool_arguments_json_for_replay,
)
from nanobot.providers.openai_responses import (
consume_sdk_stream,
convert_messages,
@@ -478,24 +483,6 @@ class OpenAICompatProvider(LLMProvider):
"""Return True for providers that reject normal OpenAI tool call IDs."""
return bool(self._spec and self._spec.name == "mistral")
@staticmethod
def _normalize_tool_call_arguments(arguments: Any) -> str:
"""Force function.arguments into a valid JSON object string."""
if isinstance(arguments, str):
stripped = arguments.strip()
if not stripped:
return "{}"
try:
parsed = json_repair.loads(stripped)
except Exception:
return "{}"
if isinstance(parsed, dict):
return json.dumps(parsed, ensure_ascii=False)
return "{}"
if isinstance(arguments, dict):
return json.dumps(arguments, ensure_ascii=False)
return "{}"
@staticmethod
def _coerce_content_to_string(content: Any) -> str | None:
"""Coerce block/list content into plain text for strict string-only APIs."""
@@ -572,7 +559,7 @@ class OpenAICompatProvider(LLMProvider):
if isinstance(function, dict):
function_clean = dict(function)
if "arguments" in function_clean:
function_clean["arguments"] = self._normalize_tool_call_arguments(
function_clean["arguments"] = tool_arguments_json_for_replay(
function_clean.get("arguments")
)
else:
@@ -1021,14 +1008,12 @@ class OpenAICompatProvider(LLMProvider):
for tc in raw_tool_calls:
tc_map = self._maybe_mapping(tc) or {}
fn = self._maybe_mapping(tc_map.get("function")) or {}
args = fn.get("arguments", {})
if isinstance(args, str):
args = json_repair.loads(args)
args = parse_tool_arguments(fn.get("arguments", {}))
ec, prov, fn_prov = _extract_tc_extras(tc)
parsed_tool_calls.append(ToolCallRequest(
id=str(tc_map.get("id") or _short_tool_id()),
name=str(fn.get("name") or ""),
arguments=args if isinstance(args, dict) else {},
arguments=args,
extra_content=ec,
provider_specific_fields=prov,
function_provider_specific_fields=fn_prov,
@@ -1064,9 +1049,7 @@ class OpenAICompatProvider(LLMProvider):
tool_calls = []
for tc in raw_tool_calls:
args = tc.function.arguments
if isinstance(args, str):
args = json_repair.loads(args)
args = parse_tool_arguments(tc.function.arguments)
ec, prov, fn_prov = _extract_tc_extras(tc)
tool_calls.append(ToolCallRequest(
id=str(getattr(tc, "id", None) or _short_tool_id()),
@@ -1207,7 +1190,7 @@ class OpenAICompatProvider(LLMProvider):
ToolCallRequest(
id=b["id"] or _short_tool_id(),
name=b["name"],
arguments=json_repair.loads(b["arguments"]) if b["arguments"] else {},
arguments=parse_tool_arguments(b["arguments"]),
extra_content=b.get("extra_content"),
provider_specific_fields=b.get("prov"),
function_provider_specific_fields=b.get("fn_prov"),