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
+72 -15
View File
@@ -1,5 +1,6 @@
"""Tool registry for dynamic tool management."""
import json
from typing import Any
from nanobot.agent.tools.base import Tool
@@ -30,6 +31,24 @@ class ToolRegistry:
"""Get a tool by name."""
return self._tools.get(name)
@staticmethod
def _lookup_key(name: str) -> str:
"""Normalize names for suggestions only; never for execution."""
return "".join(ch.lower() for ch in name if ch.isalnum())
def _suggest_name(self, name: str) -> str | None:
key = self._lookup_key(str(name or ""))
if not key:
return None
matches = [
registered
for registered in self._tools
if self._lookup_key(registered) == key
]
if len(matches) == 1:
return matches[0]
return None
def has(self, name: str) -> bool:
"""Check if a tool is registered."""
return name in self._tools
@@ -73,20 +92,23 @@ class ToolRegistry:
def prepare_call(
self,
name: str,
params: dict[str, Any],
) -> tuple[Tool | None, dict[str, Any], str | None]:
params: Any,
) -> tuple[Tool | None, Any, str | None]:
"""Resolve, cast, and validate one tool call."""
# Guard against invalid parameter types (e.g., list instead of dict)
if not isinstance(params, dict) and name in ('write_file', 'read_file'):
return None, params, (
f"Error: Tool '{name}' parameters must be a JSON object, got {type(params).__name__}. "
"Use named parameters: tool_name(param1=\"value1\", param2=\"value2\")"
)
tool = self._tools.get(name)
if not tool:
suggestion = self._suggest_name(str(name))
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
return None, params, (
f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}"
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
)
params = self._coerce_params(tool, params)
if not isinstance(params, dict):
return tool, params, (
f"Error: Tool '{name}' parameters must be a JSON object, got "
f"{type(params).__name__}. Use named parameters like "
'tool_name(param1="value1", param2="value2") matching the tool schema.'
)
cast_params = tool.cast_params(params)
@@ -97,21 +119,56 @@ class ToolRegistry:
)
return tool, cast_params, None
async def execute(self, name: str, params: dict[str, Any]) -> Any:
@classmethod
def _coerce_argument_value(cls, value: Any) -> Any:
if value is None:
return {}
if not isinstance(value, str):
return value
stripped = value.strip()
if not stripped:
return {}
if not stripped.startswith(("{", "[")):
return value
try:
parsed = json.loads(stripped)
except Exception:
return value
return parsed
@classmethod
def _coerce_params(cls, tool: Tool, params: Any) -> Any:
params = cls._coerce_argument_value(params)
return cls._unwrap_arguments_payload(tool, params)
@classmethod
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
if not isinstance(params, dict) or set(params) != {"arguments"}:
return params
properties = (tool.parameters or {}).get("properties", {})
if isinstance(properties, dict) and "arguments" in properties:
return params
return cls._coerce_argument_value(params.get("arguments"))
async def execute(self, name: str, params: Any) -> Any:
"""Execute a tool by name with given parameters."""
_HINT = "\n\n[Analyze the error above and try a different approach.]"
hint = "\n\n[Analyze the error above and try a different approach.]"
tool, params, error = self.prepare_call(name, params)
if error:
return error + _HINT
return error + hint
try:
assert tool is not None # guarded by prepare_call()
result = await tool.execute(**params)
if isinstance(result, str) and result.startswith("Error"):
return result + _HINT
return result + hint
return result
except Exception as e:
return f"Error executing {name}: {str(e)}" + _HINT
return f"Error executing {name}: {str(e)}" + hint
@property
def tool_names(self) -> list[str]: