From 34fdac21136dc1dc04a46ccb0dc50d622ab232ea Mon Sep 17 00:00:00 2001 From: potoior <110437613+potoior@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:24:20 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20merge=20system=20messages=20and=20place?= =?UTF-8?q?=20them=20first=20to=20comply=20with=20Qwen/v=E2=80=A6=20(#3913?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/dify_agent/adapters/llm/model.py | 36 +++++++-- .../dify_agent/adapters/llm/test_model.py | 81 +++++++++++++++++-- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/dify-agent/src/dify_agent/adapters/llm/model.py b/dify-agent/src/dify_agent/adapters/llm/model.py index 1c77e6c8ded..02b0f71b464 100644 --- a/dify-agent/src/dify_agent/adapters/llm/model.py +++ b/dify-agent/src/dify_agent/adapters/llm/model.py @@ -304,16 +304,40 @@ def _map_messages_to_prompt_messages( for part in (Model._get_instruction_parts(messages, model_request_parameters) or []) if part.content.strip() ] - if instruction_messages: - insert_at = next( - (index for index, message in enumerate(prompt_messages) if not isinstance(message, SystemPromptMessage)), - len(prompt_messages), - ) - prompt_messages[insert_at:insert_at] = instruction_messages + prompt_messages = _order_system_messages_first(prompt_messages, instruction_messages) return prompt_messages +def _order_system_messages_first( + prompt_messages: Sequence[PromptMessage], + instruction_messages: Sequence[SystemPromptMessage], +) -> list[PromptMessage]: + """Merge all system content into a single leading system message. + + Some providers (e.g. vLLM serving Qwen3.5/3.6 chat templates) reject any + system message that is not exactly the first message, so sorting alone is + not enough: multiple system messages must be merged into one. + """ + system_contents: list[str] = [] + non_system_messages: list[PromptMessage] = [] + for message in prompt_messages: + if isinstance(message, SystemPromptMessage): + text = message.get_text_content() + if text.strip(): + system_contents.append(text) + else: + non_system_messages.append(message) + for instruction in instruction_messages: + text = instruction.get_text_content() + if text.strip(): + system_contents.append(text) + + if not system_contents: + return non_system_messages + return [SystemPromptMessage(content="\n\n".join(system_contents)), *non_system_messages] + + def _map_model_request_to_prompt_messages(message: ModelRequest) -> list[PromptMessage]: prompt_messages: list[PromptMessage] = [] diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py index 084cdad4670..589e420bf80 100644 --- a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py +++ b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py @@ -171,12 +171,11 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(set(tools_by_name), {"weather", "incident_summary"}) self.assertEqual(tools_by_name["incident_summary"]["parameters"]["required"], ["title"]) self.assertEqual(data["prompt_messages"][0]["role"], "system") - self.assertEqual(data["prompt_messages"][0]["content"], "request system") - self.assertEqual(data["prompt_messages"][1]["content"], "be concise") - self.assertEqual(data["prompt_messages"][2]["content"], "hello") + self.assertEqual(data["prompt_messages"][0]["content"], "request system\n\nbe concise") + self.assertEqual(data["prompt_messages"][1]["content"], "hello") + self.assertEqual(data["prompt_messages"][2]["role"], "tool") self.assertEqual(data["prompt_messages"][3]["role"], "tool") - self.assertEqual(data["prompt_messages"][4]["role"], "tool") - self.assertEqual(data["prompt_messages"][5]["role"], "assistant") + self.assertEqual(data["prompt_messages"][4]["role"], "assistant") return build_stream_response( LLMResultChunk( model="demo-model", @@ -291,6 +290,78 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(usage.time_to_first_token, 0.2) self.assertEqual(usage.time_to_generate, 0.6) + async def test_request_merges_system_messages_before_history(self) -> None: + messages = [ + ModelRequest(parts=[UserPromptPart("previous user")]), + ModelResponse(parts=[TextPart(content="previous answer")]), + ModelRequest(parts=[SystemPromptPart("current system"), UserPromptPart("current user")]), + ] + request_parameters = ModelRequestParameters(instruction_parts=[InstructionPart(content="runtime instruction")]) + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + prompt_messages = payload["target"]["prompt_messages"] + + self.assertEqual( + [message["role"] for message in prompt_messages], + ["system", "user", "assistant", "user"], + ) + self.assertEqual(prompt_messages[0]["content"], "current system\n\nruntime instruction") + self.assertEqual(prompt_messages[1]["content"], "previous user") + self.assertEqual(prompt_messages[2]["content"], "previous answer") + self.assertEqual(prompt_messages[3]["content"], "current user") + return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7)) + + async with self.mock_gateway_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + ) + + response = await adapter.request( + messages, + model_settings=None, + model_request_parameters=request_parameters, + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + + async def test_request_merges_scattered_system_messages_without_instructions(self) -> None: + messages = [ + ModelRequest(parts=[SystemPromptPart("first system"), UserPromptPart("hello")]), + ModelResponse(parts=[TextPart(content="answer")]), + ModelRequest(parts=[SystemPromptPart("second system"), UserPromptPart("follow up")]), + ] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + prompt_messages = payload["target"]["prompt_messages"] + + self.assertEqual( + [message["role"] for message in prompt_messages], + ["system", "user", "assistant", "user"], + ) + self.assertEqual(prompt_messages[0]["content"], "first system\n\nsecond system") + return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7)) + + async with self.mock_gateway_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + ) + + response = await adapter.request( + messages, + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + async def test_request_maps_tool_call_only_assistant_history_to_empty_string_content(self) -> None: messages = [ ModelRequest(parts=[SystemPromptPart("request system"), UserPromptPart("hello")]),