From 1a42b9cdbb468c79b45a8a8c2a59c4a34f90120f Mon Sep 17 00:00:00 2001 From: Brad Hilton Date: Sat, 19 Sep 2026 16:47:32 +0000 Subject: [PATCH 1/3] fix: render structured tool-call arguments --- src/art_inference/append_only.py | 19 ++++++++++ tests/unit/test_append_only.py | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/art_inference/append_only.py b/src/art_inference/append_only.py index 989c1c61a..d57ed4b1f 100644 --- a/src/art_inference/append_only.py +++ b/src/art_inference/append_only.py @@ -426,6 +426,25 @@ async def chat_response_prefixes( ) async def complete(message: Mapping[str, Any]) -> list[int] | None: + calls = message.get("tool_calls") + if isinstance(calls, list): + message = { + **message, + "tool_calls": [ + { + **call, + "function": { + **call["function"], + "arguments": json.dumps(call["function"]["arguments"]), + }, + } + if isinstance(call, Mapping) + and isinstance(call.get("function"), Mapping) + and isinstance(call["function"].get("arguments"), Mapping) + else call + for call in calls + ], + } return await render( type(request).model_validate({**payload, "messages": [*messages, message]}) ) diff --git a/tests/unit/test_append_only.py b/tests/unit/test_append_only.py index 99bff1ad5..1e996b7ca 100644 --- a/tests/unit/test_append_only.py +++ b/tests/unit/test_append_only.py @@ -1,4 +1,6 @@ import asyncio +from copy import deepcopy +import json from types import SimpleNamespace from pydantic import BaseModel @@ -140,6 +142,68 @@ async def render(_): ) +@pytest.mark.parametrize("arguments", [{"id": 3}, '{ "id": 3 }']) +def test_response_observation_accepts_structured_tool_arguments(arguments): + class Function(BaseModel): + name: str + arguments: str + + class ToolCall(BaseModel): + id: str + type: str + function: Function + + class Message(BaseModel): + role: str + content: str | None = None + tool_calls: list[ToolCall] | None = None + + class Request(BaseModel): + messages: list[Message] + + tokenizer = Tokenizer() + request = Request(messages=[Message(role="user", content="question")]) + message = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call", + "type": "function", + "function": {"name": "lookup", "arguments": arguments}, + } + ], + } + original_request = request.model_dump(mode="python") + original_message = deepcopy(message) + rendered_arguments: list[str] = [] + + async def render(value: Request) -> list[int]: + if len(value.messages) == 1: + return tokenizer.encode("prompt:") + calls = value.messages[-1].tool_calls + assert calls + function = calls[0].function + rendered_arguments.append(function.arguments) + return tokenizer.encode("prompt:toolEND") + + observations = asyncio.run( + chat_response_prefixes( + tokenizer, + request, + tokenizer.encode("prompt:"), + [(message, tokenizer.encode("toolEND"), True)], + render, + ) + ) + + expected = json.dumps(arguments) if isinstance(arguments, dict) else arguments + assert rendered_arguments == [expected] + assert observations + assert request.model_dump(mode="python") == original_request + assert message == original_message + + def test_custom_stop_does_not_delete_the_template_terminator(): encode = Tokenizer().encode assert ( From c8fe6914e213c521851baa772519bb1031e9c1e8 Mon Sep 17 00:00:00 2001 From: Brad Hilton Date: Sat, 19 Sep 2026 16:50:19 +0000 Subject: [PATCH 2/3] fix: normalize mutated tool-call history --- src/art_inference/append_only.py | 26 +++++++- tests/unit/test_append_only.py | 108 +++++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/art_inference/append_only.py b/src/art_inference/append_only.py index d57ed4b1f..7eeb54c33 100644 --- a/src/art_inference/append_only.py +++ b/src/art_inference/append_only.py @@ -425,10 +425,21 @@ async def chat_response_prefixes( if name in fields ) - async def complete(message: Mapping[str, Any]) -> list[int] | None: + def openai_tool_arguments(message: Mapping[str, Any]) -> Mapping[str, Any]: + function_call = message.get("function_call") + if isinstance(function_call, Mapping) and isinstance( + function_call.get("arguments"), Mapping + ): + message = { + **message, + "function_call": { + **function_call, + "arguments": json.dumps(function_call["arguments"]), + }, + } calls = message.get("tool_calls") if isinstance(calls, list): - message = { + return { **message, "tool_calls": [ { @@ -445,6 +456,17 @@ async def complete(message: Mapping[str, Any]) -> list[int] | None: for call in calls ], } + return message + + # vLLM renders tool-call arguments as mappings and shallow-copies their + # containers, mutating historical request messages before this observer runs. + messages = [ + openai_tool_arguments(message) if isinstance(message, Mapping) else message + for message in messages + ] + + async def complete(message: Mapping[str, Any]) -> list[int] | None: + message = openai_tool_arguments(message) return await render( type(request).model_validate({**payload, "messages": [*messages, message]}) ) diff --git a/tests/unit/test_append_only.py b/tests/unit/test_append_only.py index 1e996b7ca..52d5564cc 100644 --- a/tests/unit/test_append_only.py +++ b/tests/unit/test_append_only.py @@ -142,8 +142,16 @@ async def render(_): ) -@pytest.mark.parametrize("arguments", [{"id": 3}, '{ "id": 3 }']) -def test_response_observation_accepts_structured_tool_arguments(arguments): +@pytest.mark.parametrize( + ("arguments", "legacy"), + [ + ({"id": 3}, False), + ('{ "id": 3 }', False), + ({"id": 3}, True), + ], + ids=("mapping", "string", "legacy-mapping"), +) +def test_response_observation_accepts_structured_tool_arguments(arguments, legacy): class Function(BaseModel): name: str arguments: str @@ -157,23 +165,25 @@ class Message(BaseModel): role: str content: str | None = None tool_calls: list[ToolCall] | None = None + function_call: Function | None = None class Request(BaseModel): messages: list[Message] tokenizer = Tokenizer() request = Request(messages=[Message(role="user", content="question")]) - message = { - "role": "assistant", - "content": None, - "tool_calls": [ + function = {"name": "lookup", "arguments": arguments} + message = {"role": "assistant", "content": None} + if legacy: + message["function_call"] = function + else: + message["tool_calls"] = [ { "id": "call", "type": "function", - "function": {"name": "lookup", "arguments": arguments}, + "function": function, } - ], - } + ] original_request = request.model_dump(mode="python") original_message = deepcopy(message) rendered_arguments: list[str] = [] @@ -181,10 +191,13 @@ class Request(BaseModel): async def render(value: Request) -> list[int]: if len(value.messages) == 1: return tokenizer.encode("prompt:") - calls = value.messages[-1].tool_calls - assert calls - function = calls[0].function - rendered_arguments.append(function.arguments) + completed = value.messages[-1] + if legacy: + assert completed.function_call + rendered_arguments.append(completed.function_call.arguments) + else: + assert completed.tool_calls + rendered_arguments.append(completed.tool_calls[0].function.arguments) return tokenizer.encode("prompt:toolEND") observations = asyncio.run( @@ -204,6 +217,75 @@ async def render(value: Request) -> list[int]: assert message == original_message +def test_response_observation_restores_mutated_history_tool_arguments(): + class Function(BaseModel): + name: str + arguments: str + + class ToolCall(BaseModel): + id: str + type: str + function: Function + + class Message(BaseModel): + role: str + content: str | None = None + tool_calls: list[ToolCall] | None = None + + class Request(BaseModel): + messages: list[Message] + + tokenizer = Tokenizer() + request = Request( + messages=[ + Message(role="user", content="question"), + Message( + role="assistant", + tool_calls=[ + ToolCall( + id="prior", + type="function", + function=Function(name="lookup", arguments='{"id": 3}'), + ) + ], + ), + ] + ) + prior = request.messages[-1].tool_calls + assert prior + object.__setattr__(prior[0].function, "arguments", {"id": 3}) + rendered_arguments = [] + + async def render(value: Request) -> list[int]: + if len(value.messages) == 2: + return tokenizer.encode("prompt:") + calls = value.messages[-2].tool_calls + assert calls + rendered_arguments.append(calls[0].function.arguments) + return tokenizer.encode("prompt:answerEND") + + with pytest.warns(UserWarning, match="Expected `str`"): + observations = asyncio.run( + chat_response_prefixes( + tokenizer, + request, + tokenizer.encode("prompt:"), + [ + ( + {"role": "assistant", "content": "answer"}, + tokenizer.encode("answerEND"), + True, + ) + ], + render, + ) + ) + + assert rendered_arguments == ['{"id": 3}'] + assert observations + assert prior[0].function.arguments == {"id": 3} + + def test_custom_stop_does_not_delete_the_template_terminator(): encode = Tokenizer().encode assert ( From 7f68b0c0e5a5404f8eefbc236a06dbf53b2a241d Mon Sep 17 00:00:00 2001 From: Brad Hilton Date: Sat, 19 Sep 2026 16:59:35 +0000 Subject: [PATCH 3/3] test: consolidate tool argument observer coverage --- tests/unit/test_append_only.py | 196 +++++++++++++-------------------- 1 file changed, 75 insertions(+), 121 deletions(-) diff --git a/tests/unit/test_append_only.py b/tests/unit/test_append_only.py index 52d5564cc..4c761020d 100644 --- a/tests/unit/test_append_only.py +++ b/tests/unit/test_append_only.py @@ -142,150 +142,104 @@ async def render(_): ) +class StrictFunction(BaseModel): + name: str + arguments: str + + +class StrictToolCall(BaseModel): + id: str + type: str + function: StrictFunction + + +class StrictMessage(BaseModel): + role: str + content: str | None = None + tool_calls: list[StrictToolCall] | None = None + function_call: StrictFunction | None = None + + +class StrictRequest(BaseModel): + messages: list[StrictMessage] + + +@pytest.mark.filterwarnings("ignore:Pydantic serializer warnings") @pytest.mark.parametrize( - ("arguments", "legacy"), + ("arguments", "legacy", "historical"), [ - ({"id": 3}, False), - ('{ "id": 3 }', False), - ({"id": 3}, True), + ({"id": 3}, False, False), + ('{ "id": 3 }', False, False), + ({"id": 3}, True, False), + ({"previous": True}, False, True), ], - ids=("mapping", "string", "legacy-mapping"), + ids=("mapping", "string", "legacy-mapping", "historical-mapping"), ) -def test_response_observation_accepts_structured_tool_arguments(arguments, legacy): - class Function(BaseModel): - name: str - arguments: str - - class ToolCall(BaseModel): - id: str - type: str - function: Function - - class Message(BaseModel): - role: str - content: str | None = None - tool_calls: list[ToolCall] | None = None - function_call: Function | None = None - - class Request(BaseModel): - messages: list[Message] - +def test_response_observation_accepts_structured_tool_arguments( + arguments, legacy, historical +): tokenizer = Tokenizer() - request = Request(messages=[Message(role="user", content="question")]) - function = {"name": "lookup", "arguments": arguments} - message = {"role": "assistant", "content": None} - if legacy: - message["function_call"] = function - else: - message["tool_calls"] = [ - { - "id": "call", - "type": "function", - "function": function, - } - ] - original_request = request.model_dump(mode="python") + messages = [StrictMessage(role="user", content="question")] + if historical: + function = StrictFunction(name="previous", arguments="{}") + object.__setattr__(function, "arguments", arguments) + messages.extend( + [ + StrictMessage( + role="assistant", + tool_calls=[ + StrictToolCall( + id="previous", type="function", function=function + ) + ], + ), + StrictMessage(role="user", content="next"), + ] + ) + request = StrictRequest(messages=messages) + message = {"role": "assistant", "content": "answer"} + if not historical: + function = {"name": "lookup", "arguments": arguments} + if legacy: + message["function_call"] = function + else: + message["tool_calls"] = [ + {"id": "call", "type": "function", "function": function} + ] + original_request = request.model_dump(mode="python", warnings=False) original_message = deepcopy(message) - rendered_arguments: list[str] = [] + rendered_arguments: list[list[str]] = [] - async def render(value: Request) -> list[int]: - if len(value.messages) == 1: + async def render(value: StrictRequest) -> list[int]: + if len(value.messages) == len(request.messages): return tokenizer.encode("prompt:") - completed = value.messages[-1] - if legacy: - assert completed.function_call - rendered_arguments.append(completed.function_call.arguments) - else: - assert completed.tool_calls - rendered_arguments.append(completed.tool_calls[0].function.arguments) - return tokenizer.encode("prompt:toolEND") + completed: list[str] = [] + for rendered_message in value.messages: + completed.extend( + call.function.arguments for call in rendered_message.tool_calls or [] + ) + if rendered_message.function_call: + completed.append(rendered_message.function_call.arguments) + rendered_arguments.append(completed) + return tokenizer.encode("prompt:answerEND") observations = asyncio.run( chat_response_prefixes( tokenizer, request, tokenizer.encode("prompt:"), - [(message, tokenizer.encode("toolEND"), True)], + [(message, tokenizer.encode("answerEND"), True)], render, ) ) expected = json.dumps(arguments) if isinstance(arguments, dict) else arguments - assert rendered_arguments == [expected] + assert rendered_arguments == [[expected]] assert observations - assert request.model_dump(mode="python") == original_request + assert request.model_dump(mode="python", warnings=False) == original_request assert message == original_message -def test_response_observation_restores_mutated_history_tool_arguments(): - class Function(BaseModel): - name: str - arguments: str - - class ToolCall(BaseModel): - id: str - type: str - function: Function - - class Message(BaseModel): - role: str - content: str | None = None - tool_calls: list[ToolCall] | None = None - - class Request(BaseModel): - messages: list[Message] - - tokenizer = Tokenizer() - request = Request( - messages=[ - Message(role="user", content="question"), - Message( - role="assistant", - tool_calls=[ - ToolCall( - id="prior", - type="function", - function=Function(name="lookup", arguments='{"id": 3}'), - ) - ], - ), - ] - ) - prior = request.messages[-1].tool_calls - assert prior - object.__setattr__(prior[0].function, "arguments", {"id": 3}) - rendered_arguments = [] - - async def render(value: Request) -> list[int]: - if len(value.messages) == 2: - return tokenizer.encode("prompt:") - calls = value.messages[-2].tool_calls - assert calls - rendered_arguments.append(calls[0].function.arguments) - return tokenizer.encode("prompt:answerEND") - - with pytest.warns(UserWarning, match="Expected `str`"): - observations = asyncio.run( - chat_response_prefixes( - tokenizer, - request, - tokenizer.encode("prompt:"), - [ - ( - {"role": "assistant", "content": "answer"}, - tokenizer.encode("answerEND"), - True, - ) - ], - render, - ) - ) - - assert rendered_arguments == ['{"id": 3}'] - assert observations - assert prior[0].function.arguments == {"id": 3} - - def test_custom_stop_does_not_delete_the_template_terminator(): encode = Tokenizer().encode assert (