diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6b559b9c45..037606ced8 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -2797,6 +2797,13 @@ def _normalise_responses_input(payload: ResponsesRequest) -> list[ChatMessage]: output = item.output if not isinstance(output, str): output = json.dumps(output) + if not output: + # An empty/falsy result (e.g. an image-only tool output whose + # payload lives outside `output`) would otherwise build a + # role="tool" ChatMessage with content="", which the strict + # validator rejects with a 500. Emit a placeholder so the turn + # normalises gracefully. + output = "(no output)" messages.append( ChatMessage( role = "tool", diff --git a/studio/backend/tests/test_responses_tool_passthrough.py b/studio/backend/tests/test_responses_tool_passthrough.py index 2f1161c329..be99454483 100644 --- a/studio/backend/tests/test_responses_tool_passthrough.py +++ b/studio/backend/tests/test_responses_tool_passthrough.py @@ -374,6 +374,44 @@ class TestNormaliseResponsesInputWithTools: # Content is serialised so llama-server sees a string. assert json.loads(msgs[0].content) == [{"type": "output_text", "text": "ok"}] + def test_empty_tool_output_replaced_with_placeholder(self): + """Image-only tool results (Anthropic format) send empty output. + Before the fix this crashed the validator with + "role=\"tool\" messages require non-empty \"content\"". + Now it emits "(no output)" so the turn normalises cleanly.""" + payload = ResponsesRequest( + input = [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": "", + } + ], + ) + msgs = _normalise_responses_input(payload) + assert msgs[0].role == "tool" + assert msgs[0].tool_call_id == "call_1" + assert msgs[0].content == "(no output)" + # Round-trip through ChatMessage validator must not raise. + ChatMessage(**msgs[0].model_dump(exclude_none = True)) + + def test_empty_list_output_serialised_to_json_array(self): + """Empty list output is not falsy after json.dumps (becomes "[]"), + so it should not trigger the placeholder.""" + payload = ResponsesRequest( + input = [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": [], + } + ], + ) + msgs = _normalise_responses_input(payload) + assert msgs[0].role == "tool" + assert msgs[0].content == "[]" + ChatMessage(**msgs[0].model_dump(exclude_none = True)) + # ===================================================================== # Response mapping — tool_calls → function_call output items