[Bugfix][Tool Parser] poolside_v1: accept tool calls without newline after function name (#47311)

Signed-off-by: Joe Rowell <joerowell4@gmail.com>

Joe Rowell committed Jul 2, 2026 at 21:08 UTC 258f8de91f99b40a4dfb234e55a09e9493c6ece8
2 files changed +103 -1
tests/tool_parsers/test_poolside_v1_tool_parser.py
+102
@@ -220,6 +220,108 @@ def test_responses_extract_tool_calls_with_flat_tools() -> None:
220 assert args["content"] == content
221
222
223 +def _weather_tool() -> dict[str, Any]:
224 + return {
225 + "type": "function",
226 + "function": {
227 + "name": "get_weather",
228 + "description": "Get the weather for a city",
229 + "parameters": {
230 + "type": "object",
231 + "properties": {"city": {"type": "string"}},
232 + "required": ["city"],
233 + },
234 + },
235 + }
236 +
237 +
238 +def _build_weather_request(*, tool_choice: str) -> ChatCompletionRequest:
239 + return ChatCompletionRequest.model_validate(
240 + {
241 + "model": "poolside-test",
242 + "messages": [{"role": "user", "content": "weather in Paris?"}],
243 + "tools": [_weather_tool()],
244 + "tool_choice": tool_choice,
245 + }
246 + )
247 +
248 +
249 +def test_no_newline_after_name_non_streaming() -> None:
250 + request = _build_weather_request(tool_choice="auto")
251 + parser = _make_parser(request)
252 +
253 + model_output = (
254 + "<tool_call>get_weather"
255 + "<arg_key>city</arg_key><arg_value>Paris</arg_value>"
256 + "</tool_call>"
257 + )
258 +
259 + result = parser.extract_tool_calls(model_output, request)
260 +
261 + assert result.tools_called
262 + assert len(result.tool_calls) == 1
263 + assert result.tool_calls[0].function.name == "get_weather"
264 + args = json.loads(result.tool_calls[0].function.arguments)
265 + assert args == {"city": "Paris"}
266 +
267 +
268 +def test_newline_after_name_still_parses_non_streaming() -> None:
269 + request = _build_weather_request(tool_choice="auto")
270 + parser = _make_parser(request)
271 +
272 + model_output = (
273 + "<tool_call>get_weather\n"
274 + "<arg_key>city</arg_key>\n<arg_value>Paris</arg_value>\n"
275 + "</tool_call>"
276 + )
277 +
278 + result = parser.extract_tool_calls(model_output, request)
279 +
280 + assert result.tools_called
281 + assert result.tool_calls[0].function.name == "get_weather"
282 + args = json.loads(result.tool_calls[0].function.arguments)
283 + assert args == {"city": "Paris"}
284 +
285 +
286 +def test_no_newline_after_name_streaming() -> None:
287 + request = _build_weather_request(tool_choice="auto")
288 + parser = _make_parser(request)
289 +
290 + model_output = (
291 + "<tool_call>get_weather"
292 + "<arg_key>city</arg_key><arg_value>Paris</arg_value>"
293 + "</tool_call>"
294 + )
295 +
296 + name = ""
297 + args = ""
298 + prev = ""
299 + for ch in model_output:
300 + cur = prev + ch
301 + delta = parser.extract_tool_calls_streaming(
302 + previous_text=prev,
303 + current_text=cur,
304 + delta_text=ch,
305 + previous_token_ids=[],
306 + current_token_ids=[],
307 + delta_token_ids=[],
308 + request=request,
309 + )
310 + prev = cur
311 + if delta is None:
312 + continue
313 + for tc in delta.tool_calls or []:
314 + if tc.function is None:
315 + continue
316 + if tc.function.name:
317 + name += tc.function.name
318 + if tc.function.arguments:
319 + args += tc.function.arguments
320 +
321 + assert name == "get_weather"
322 + assert json.loads(args) == {"city": "Paris"}
323 +
324 +
325 def _stream_partial_start_token(request: ResponsesRequest):
326 parser = _make_parser(request)
327 delta = parser.tool_call_start_token[0]
vllm/tool_parsers/poolside_v1_tool_parser.py
+1 -1
@@ -76,7 +76,7 @@ class PoolsideV1ToolParser(ToolParser):
76
77 self.func_call_regex = re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL)
78 self.func_detail_regex = re.compile(
79 - r"<tool_call>([^\n]*)\n(.*)</tool_call>", re.DOTALL
79 + r"<tool_call>\s*([^\n<]+?)\s*\n?\s*(<arg_key>.*?)?</tool_call>", re.DOTALL
80 )
81 self.func_arg_regex = re.compile(
82 r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>", re.DOTALL