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
@@ -80,3 +80,17 @@ def test_convert_user_content_coerces_mixed_typeless():
])
assert result[0] == {"type": "text", "text": "42"}
assert result[1] == {"type": "text", "text": str({"key": "val"})}
def test_convert_assistant_message_repairs_history_tool_arguments():
blocks = AnthropicProvider._assistant_blocks({
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "toolu_1",
"function": {"name": "read_file", "arguments": '{path:"foo.txt"}'},
}],
})
assert blocks[0]["type"] == "tool_use"
assert blocks[0]["input"] == {"path": "foo.txt"}
+10
View File
@@ -161,6 +161,16 @@ def test_build_kwargs_converts_messages_tools_and_tool_results() -> None:
assert kwargs["toolConfig"]["toolChoice"] == {"any": {}}
def test_tool_use_block_repairs_history_tool_arguments() -> None:
block = BedrockProvider._tool_use_block({
"id": "toolu_1",
"function": {"name": "read_file", "arguments": '{path:"foo.txt"}'},
})
assert block is not None
assert block["toolUse"]["input"] == {"path": "foo.txt"}
def test_build_kwargs_keeps_tool_config_for_historical_tool_blocks_without_tools() -> None:
provider = BedrockProvider(region="us-east-1", client=FakeClient())
messages = [
+28 -1
View File
@@ -54,6 +54,15 @@ def _fake_tool_call_response() -> SimpleNamespace:
return SimpleNamespace(choices=[choice], usage=usage)
def _fake_tool_call_response_with_arguments(arguments) -> SimpleNamespace:
"""Build a minimal chat response with caller-supplied tool arguments."""
function = SimpleNamespace(name="optional_tool", arguments=arguments)
tool_call = SimpleNamespace(id="call_123", type="function", function=function)
message = SimpleNamespace(content=None, tool_calls=[tool_call], reasoning_content=None)
choice = SimpleNamespace(message=message, finish_reason="tool_calls")
return SimpleNamespace(choices=[choice], usage=SimpleNamespace())
def _fake_responses_response(content: str = "ok") -> MagicMock:
"""Build a minimal Responses API response object."""
resp = MagicMock()
@@ -611,6 +620,24 @@ async def test_openai_compat_preserves_extra_content_on_tool_calls() -> None:
assert serialized["function"]["provider_specific_fields"] == {"inner": "value"}
def test_openai_compat_parse_preserves_malformed_tool_arguments() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
result = provider._parse(_fake_tool_call_response_with_arguments('{path:"foo.txt"}'))
assert result.tool_calls[0].arguments == '{path:"foo.txt"}'
def test_openai_compat_parse_preserves_array_tool_arguments() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
result = provider._parse(_fake_tool_call_response_with_arguments('["foo.txt"]'))
assert result.tool_calls[0].arguments == ["foo.txt"]
def test_openai_model_passthrough() -> None:
"""OpenAI models pass through unchanged."""
spec = find_by_name("openai")
@@ -1110,7 +1137,7 @@ def test_openai_compat_stringifies_dict_tool_arguments() -> None:
assert sanitized[1]["tool_calls"][0]["function"]["arguments"] == '{"cmd": "ls -la"}'
def test_openai_compat_repairs_non_json_tool_arguments_string() -> None:
def test_openai_compat_repairs_object_like_history_tool_arguments_string() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
+90 -4
View File
@@ -155,6 +155,19 @@ class TestConvertMessages:
assert items[0]["call_id"] == "call_abc"
assert items[0]["id"] == "fc_1"
assert items[0]["name"] == "get_weather"
assert items[0]["arguments"] == '{"city": "SF"}'
def test_assistant_tool_call_history_repairs_malformed_arguments(self):
_, items = convert_messages([{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_abc|fc_1",
"function": {"name": "read_file", "arguments": '{path:"foo.txt"}'},
}],
}])
assert json.loads(items[0]["arguments"]) == {"path": "foo.txt"}
def test_duplicate_response_item_ids_are_made_unique(self):
"""Codex rejects replayed Responses input items with duplicate ids."""
@@ -367,7 +380,7 @@ class TestParseResponseOutput:
assert result.tool_calls[0].id == "call_1|fc_1"
def test_malformed_tool_arguments_logged(self):
"""Malformed JSON arguments should log a warning and fallback."""
"""Malformed JSON arguments should log a warning and remain non-object."""
resp = {
"output": [{
"type": "function_call",
@@ -378,10 +391,29 @@ class TestParseResponseOutput:
}
with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
result = parse_response_output(resp)
assert result.tool_calls[0].arguments == {"raw": "{bad json"}
assert result.tool_calls[0].arguments == "{bad json"
mock_logger.warning.assert_called_once()
assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args)
@pytest.mark.parametrize("arguments", [[], False, 0])
def test_falsy_non_object_tool_arguments_preserved(self, arguments):
resp = {
"output": [{
"type": "function_call",
"call_id": "c1",
"id": "fc1",
"name": "f",
"arguments": arguments,
}],
"status": "completed",
"usage": {},
}
result = parse_response_output(resp)
assert result.tool_calls[0].arguments == arguments
assert type(result.tool_calls[0].arguments) is type(arguments)
def test_reasoning_content_extracted(self):
resp = {
"output": [
@@ -611,6 +643,38 @@ class TestConsumeSse:
},
]
@pytest.mark.asyncio
@pytest.mark.parametrize("arguments", [[], False, 0])
async def test_falsy_non_object_tool_arguments_preserved(self, arguments):
response = _SseResponse([
{
"type": "response.output_item.added",
"item": {
"type": "function_call",
"call_id": "c1",
"id": "fc1",
"name": "f",
"arguments": "",
},
},
{
"type": "response.output_item.done",
"item": {
"type": "function_call",
"call_id": "c1",
"id": "fc1",
"name": "f",
"arguments": arguments,
},
},
{"type": "response.completed", "response": {"status": "completed"}},
])
_, tool_calls, _, _, _ = await consume_sse_with_reasoning(response)
assert tool_calls[0].arguments == arguments
assert type(tool_calls[0].arguments) is type(arguments)
# ======================================================================
# parsing - consume_sdk_stream
@@ -764,6 +828,28 @@ class TestConsumeSdkStream:
},
]
@pytest.mark.asyncio
@pytest.mark.parametrize("arguments", [[], False, 0])
async def test_falsy_non_object_tool_arguments_preserved(self, arguments):
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
item_added.name = "f"
ev1 = MagicMock(type="response.output_item.added", item=item_added)
item_done = MagicMock(type="function_call", call_id="c1", id="fc1")
item_done.name = "f"
item_done.arguments = arguments
ev2 = MagicMock(type="response.output_item.done", item=item_done)
resp_obj = MagicMock(status="completed", usage=None, output=[])
ev3 = MagicMock(type="response.completed", response=resp_obj)
async def stream():
for e in [ev1, ev2, ev3]:
yield e
_, tool_calls, _, _, _ = await consume_sdk_stream(stream())
assert tool_calls[0].arguments == arguments
assert type(tool_calls[0].arguments) is type(arguments)
@pytest.mark.asyncio
async def test_usage_extracted(self):
usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15)
@@ -811,7 +897,7 @@ class TestConsumeSdkStream:
@pytest.mark.asyncio
async def test_malformed_tool_args_logged(self):
"""Malformed JSON in streaming tool args should log a warning."""
"""Malformed JSON in streaming tool args should log a warning and remain non-object."""
item_added = MagicMock(type="function_call", call_id="c1", id="fc1", arguments="")
item_added.name = "f"
ev1 = MagicMock(type="response.output_item.added", item=item_added)
@@ -828,6 +914,6 @@ class TestConsumeSdkStream:
with patch("nanobot.providers.openai_responses.parsing.logger") as mock_logger:
_, tool_calls, _, _, _ = await consume_sdk_stream(stream())
assert tool_calls[0].arguments == {"raw": "{bad"}
assert tool_calls[0].arguments == "{bad"
mock_logger.warning.assert_called_once()
assert "Failed to parse tool call arguments" in str(mock_logger.warning.call_args)
@@ -0,0 +1,30 @@
"""Shared tool-argument parsing policy tests."""
from nanobot.providers.base import (
parse_tool_arguments,
tool_arguments_json_for_replay,
tool_arguments_object_for_replay,
)
def test_parse_tool_arguments_preserves_malformed_executable_arguments() -> None:
assert parse_tool_arguments('{path:"foo.txt"}') == '{path:"foo.txt"}'
def test_parse_tool_arguments_preserves_non_object_executable_arguments() -> None:
assert parse_tool_arguments('["foo.txt"]') == ["foo.txt"]
assert parse_tool_arguments("false") is False
assert parse_tool_arguments("null") == "null"
def test_tool_arguments_object_for_replay_repairs_object_like_history_arguments() -> None:
assert tool_arguments_object_for_replay('{path:"foo.txt"}') == {"path": "foo.txt"}
def test_tool_arguments_object_for_replay_keeps_history_object_shaped() -> None:
for arguments in ['["foo.txt"]', "false", "null", "0", ["foo.txt"], False, None, 0]:
assert tool_arguments_object_for_replay(arguments) == {}
def test_tool_arguments_json_for_replay_returns_object_string() -> None:
assert tool_arguments_json_for_replay('{path:"foo.txt"}') == '{"path": "foo.txt"}'