diff --git a/miles/rollout/generate_utils/tool_call_utils.py b/miles/rollout/generate_utils/tool_call_utils.py index fc2a87de33f..951503e3c81 100644 --- a/miles/rollout/generate_utils/tool_call_utils.py +++ b/miles/rollout/generate_utils/tool_call_utils.py @@ -16,6 +16,7 @@ from miles.utils.types import Sample _DUMMY_USER = {"role": "user", "content": "dummy"} +_INVALID_TOOL_ARGUMENTS_MESSAGE = "Error: Tool arguments must be a valid JSON object." def create_tool_call_parser(tool_specs, tool_call_parser): @@ -40,21 +41,36 @@ async def _execute_tool_call( ) -> dict[str, Any]: if isinstance(call, ChatCompletionMessageToolCall): name = call.function.name - params = json.loads(call.function.arguments) if call.function.arguments else {} + raw_arguments = call.function.arguments tool_call_id = call.id elif isinstance(call, ToolCallItem): name = call.name - params = json.loads(call.parameters) if call.parameters else {} + raw_arguments = call.parameters tool_call_id = f"call_{uuid.uuid4().hex[:24]}" else: raise TypeError(f"Unsupported tool call type: {type(call)}") - result = await execute_one(name, params) - assert isinstance(result, str) + params = _parse_tool_arguments(raw_arguments) + if params is None: + result = _INVALID_TOOL_ARGUMENTS_MESSAGE + else: + result = await execute_one(name, params) + assert isinstance(result, str) return {"role": "tool", "tool_call_id": tool_call_id, "content": result, "name": name} +def _parse_tool_arguments(raw_arguments: str | None) -> dict[str, Any] | None: + """Return executor kwargs, or None when recognized arguments are invalid.""" + if not raw_arguments: + return {} + try: + params = json.loads(raw_arguments) + except json.JSONDecodeError: + return None + return params if isinstance(params, dict) else None + + def update_sample_with_tool_responses(sample: Sample, tool_messages: list[dict[str, Any]], tokenizer): next_obs_tokens_ids: list[int] = tokenize_tool_responses(tool_messages, tokenizer=tokenizer) sample.response += tokenizer.decode(next_obs_tokens_ids) diff --git a/tests/fast/rollout/generate_hub/test_tool_call_utils.py b/tests/fast/rollout/generate_hub/test_tool_call_utils.py index 2b84ae640a2..34e1c3d8641 100644 --- a/tests/fast/rollout/generate_hub/test_tool_call_utils.py +++ b/tests/fast/rollout/generate_hub/test_tool_call_utils.py @@ -1,6 +1,15 @@ +from unittest.mock import AsyncMock + import pytest +from openai.types.chat import ChatCompletionMessageToolCall +from sglang.srt.function_call.core_types import ToolCallItem -from miles.rollout.generate_utils.tool_call_utils import _DUMMY_USER, _build_dummy_assistant, tokenize_tool_responses +from miles.rollout.generate_utils.tool_call_utils import ( + _DUMMY_USER, + _build_dummy_assistant, + _execute_tool_call, + tokenize_tool_responses, +) from miles.utils.processing_utils import load_tokenizer TOOL_CALL_TEST_MODELS = [ @@ -59,6 +68,59 @@ ] +def _make_tool_call(call_type: str, arguments: str) -> ToolCallItem | ChatCompletionMessageToolCall: + if call_type == "sglang": + return ToolCallItem(tool_index=0, name="bash", parameters=arguments) + return ChatCompletionMessageToolCall( + id="call_test", + type="function", + function={"name": "bash", "arguments": arguments}, + ) + + +class TestExecuteToolCall: + @pytest.mark.asyncio + @pytest.mark.parametrize("call_type", ["sglang", "openai"]) + @pytest.mark.parametrize( + ("arguments", "expected_params"), + [ + ('{"cmd": "pytest"}', {"cmd": "pytest"}), + ("", {}), + ], + ) + async def test_valid_object_arguments_reach_executor(self, call_type, arguments, expected_params): + execute_one = AsyncMock(return_value="ok") + + message = await _execute_tool_call(_make_tool_call(call_type, arguments), execute_one) + + execute_one.assert_awaited_once_with("bash", expected_params) + assert message["role"] == "tool" + assert message["content"] == "ok" + assert message["name"] == "bash" + assert message["tool_call_id"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("call_type", ["sglang", "openai"]) + @pytest.mark.parametrize( + "arguments", + [ + pytest.param('{"cmd": "pytest"', id="malformed"), + pytest.param("[1, 2]", id="list"), + pytest.param("42", id="number"), + ], + ) + async def test_invalid_arguments_return_tool_error_without_execution(self, call_type, arguments): + execute_one = AsyncMock(return_value="must not run") + + message = await _execute_tool_call(_make_tool_call(call_type, arguments), execute_one) + + execute_one.assert_not_awaited() + assert message["role"] == "tool" + assert message["content"] == "Error: Tool arguments must be a valid JSON object." + assert message["name"] == "bash" + assert message["tool_call_id"] + + class TestTokenizeToolResponses: @pytest.mark.parametrize("model_name", ["Qwen/Qwen3-0.6B"]) def test_snapshot(self, model_name):