From 6b54134017d572a094f6f5ab5315d62521891055 Mon Sep 17 00:00:00 2001 From: siddhantbhagat8 <103000056+siddhantbhagat8@users.noreply.github.com> Date: Tue, 2 Dec 2025 00:08:16 -0800 Subject: [PATCH 1/3] Insert Instructions at the end of System Prompt --- pydantic_ai_slim/pydantic_ai/models/anthropic.py | 2 +- pydantic_ai_slim/pydantic_ai/models/bedrock.py | 2 +- pydantic_ai_slim/pydantic_ai/models/cohere.py | 5 ++++- pydantic_ai_slim/pydantic_ai/models/gemini.py | 2 +- pydantic_ai_slim/pydantic_ai/models/google.py | 2 +- pydantic_ai_slim/pydantic_ai/models/groq.py | 7 ++++++- .../pydantic_ai/models/huggingface.py | 5 ++++- pydantic_ai_slim/pydantic_ai/models/mistral.py | 5 ++++- pydantic_ai_slim/pydantic_ai/models/openai.py | 15 +++++++++++++-- 9 files changed, 35 insertions(+), 10 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index ecdb9fe61f..1df861e84e 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -853,7 +853,7 @@ async def _map_message( # noqa: C901 else: assert_never(m) if instructions := self._get_instructions(messages, model_request_parameters): - system_prompt_parts.insert(0, instructions) + system_prompt_parts.append(instructions) system_prompt = '\n\n'.join(system_prompt_parts) # Add cache_control to the last message content if anthropic_cache_messages is enabled diff --git a/pydantic_ai_slim/pydantic_ai/models/bedrock.py b/pydantic_ai_slim/pydantic_ai/models/bedrock.py index ff03460904..abc60e1fd1 100644 --- a/pydantic_ai_slim/pydantic_ai/models/bedrock.py +++ b/pydantic_ai_slim/pydantic_ai/models/bedrock.py @@ -615,7 +615,7 @@ async def _map_messages( # noqa: C901 last_message = cast(dict[str, Any], current_message) if instructions := self._get_instructions(messages, model_request_parameters): - system_prompt.insert(0, {'text': instructions}) + system_prompt.append({'text': instructions}) return system_prompt, processed_messages diff --git a/pydantic_ai_slim/pydantic_ai/models/cohere.py b/pydantic_ai_slim/pydantic_ai/models/cohere.py index 60ae329065..bfd0ab0838 100644 --- a/pydantic_ai_slim/pydantic_ai/models/cohere.py +++ b/pydantic_ai_slim/pydantic_ai/models/cohere.py @@ -234,6 +234,9 @@ def _map_messages( ) -> list[ChatMessageV2]: """Just maps a `pydantic_ai.Message` to a `cohere.ChatMessageV2`.""" cohere_messages: list[ChatMessageV2] = [] + system_prompt_count = sum( + 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) + ) for message in messages: if isinstance(message, ModelRequest): cohere_messages.extend(self._map_user_message(message)) @@ -271,7 +274,7 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - cohere_messages.insert(0, SystemChatMessageV2(role='system', content=instructions)) + cohere_messages.insert(system_prompt_count, SystemChatMessageV2(role='system', content=instructions)) return cohere_messages def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[ToolV2]: diff --git a/pydantic_ai_slim/pydantic_ai/models/gemini.py b/pydantic_ai_slim/pydantic_ai/models/gemini.py index 4da92018fd..7cc1eca0ff 100644 --- a/pydantic_ai_slim/pydantic_ai/models/gemini.py +++ b/pydantic_ai_slim/pydantic_ai/models/gemini.py @@ -363,7 +363,7 @@ async def _message_to_gemini_content( else: assert_never(m) if instructions := self._get_instructions(messages, model_request_parameters): - sys_prompt_parts.insert(0, _GeminiTextPart(text=instructions)) + sys_prompt_parts.append(_GeminiTextPart(text=instructions)) return sys_prompt_parts, contents async def _map_user_prompt(self, part: UserPromptPart) -> list[_GeminiPartUnion]: diff --git a/pydantic_ai_slim/pydantic_ai/models/google.py b/pydantic_ai_slim/pydantic_ai/models/google.py index 89290ea3ce..4ead464808 100644 --- a/pydantic_ai_slim/pydantic_ai/models/google.py +++ b/pydantic_ai_slim/pydantic_ai/models/google.py @@ -588,7 +588,7 @@ async def _map_messages( contents = [{'role': 'user', 'parts': [{'text': ''}]}] if instructions := self._get_instructions(messages, model_request_parameters): - system_parts.insert(0, {'text': instructions}) + system_parts.append({'text': instructions}) system_instruction = ContentDict(role='user', parts=system_parts) if system_parts else None return system_instruction, contents diff --git a/pydantic_ai_slim/pydantic_ai/models/groq.py b/pydantic_ai_slim/pydantic_ai/models/groq.py index 64f7ddcf85..06f8e8c0cb 100644 --- a/pydantic_ai_slim/pydantic_ai/models/groq.py +++ b/pydantic_ai_slim/pydantic_ai/models/groq.py @@ -395,6 +395,9 @@ def _map_messages( ) -> list[chat.ChatCompletionMessageParam]: """Just maps a `pydantic_ai.Message` to a `groq.types.ChatCompletionMessageParam`.""" groq_messages: list[chat.ChatCompletionMessageParam] = [] + system_prompt_count = sum( + 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) + ) for message in messages: if isinstance(message, ModelRequest): groq_messages.extend(self._map_user_message(message)) @@ -428,7 +431,9 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - groq_messages.insert(0, chat.ChatCompletionSystemMessageParam(role='system', content=instructions)) + groq_messages.insert( + system_prompt_count, chat.ChatCompletionSystemMessageParam(role='system', content=instructions) + ) return groq_messages @staticmethod diff --git a/pydantic_ai_slim/pydantic_ai/models/huggingface.py b/pydantic_ai_slim/pydantic_ai/models/huggingface.py index 790b30bec3..a855113289 100644 --- a/pydantic_ai_slim/pydantic_ai/models/huggingface.py +++ b/pydantic_ai_slim/pydantic_ai/models/huggingface.py @@ -327,6 +327,9 @@ async def _map_messages( ) -> list[ChatCompletionInputMessage | ChatCompletionOutputMessage]: """Just maps a `pydantic_ai.Message` to a `huggingface_hub.ChatCompletionInputMessage`.""" hf_messages: list[ChatCompletionInputMessage | ChatCompletionOutputMessage] = [] + system_prompt_count = sum( + 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) + ) for message in messages: if isinstance(message, ModelRequest): async for item in self._map_user_message(message): @@ -361,7 +364,7 @@ async def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - hf_messages.insert(0, ChatCompletionInputMessage(content=instructions, role='system')) # type: ignore + hf_messages.insert(system_prompt_count, ChatCompletionInputMessage(content=instructions, role='system')) # type: ignore return hf_messages @staticmethod diff --git a/pydantic_ai_slim/pydantic_ai/models/mistral.py b/pydantic_ai_slim/pydantic_ai/models/mistral.py index cefa28e9dc..6fff29ca22 100644 --- a/pydantic_ai_slim/pydantic_ai/models/mistral.py +++ b/pydantic_ai_slim/pydantic_ai/models/mistral.py @@ -528,6 +528,9 @@ def _map_messages( ) -> list[MistralMessages]: """Just maps a `pydantic_ai.Message` to a `MistralMessage`.""" mistral_messages: list[MistralMessages] = [] + system_prompt_count = sum( + 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) + ) for message in messages: if isinstance(message, ModelRequest): mistral_messages.extend(self._map_user_message(message)) @@ -557,7 +560,7 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - mistral_messages.insert(0, MistralSystemMessage(content=instructions)) + mistral_messages.insert(system_prompt_count, MistralSystemMessage(content=instructions)) # Post-process messages to insert fake assistant message after tool message if followed by user message # to work around `Unexpected role 'user' after role 'tool'` error. diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 10af284ee8..5581448b28 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -831,7 +831,11 @@ async def _map_messages( ) -> list[chat.ChatCompletionMessageParam]: """Just maps a `pydantic_ai.Message` to a `openai.types.ChatCompletionMessageParam`.""" openai_messages: list[chat.ChatCompletionMessageParam] = [] + system_prompt_count = 0 for message in messages: + for part in message.parts: + if isinstance(part, SystemPromptPart): + system_prompt_count += 1 if isinstance(message, ModelRequest): async for item in self._map_user_message(message): openai_messages.append(item) @@ -840,7 +844,9 @@ async def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - openai_messages.insert(0, chat.ChatCompletionSystemMessageParam(content=instructions, role='system')) + openai_messages.insert( + system_prompt_count, chat.ChatCompletionSystemMessageParam(content=instructions, role='system') + ) return openai_messages @staticmethod @@ -1313,7 +1319,12 @@ async def _responses_create( # noqa: C901 # > Response input messages must contain the word 'json' in some form to use 'text.format' of type 'json_object'. # Apparently they're only checking input messages for "JSON", not instructions. assert isinstance(instructions, str) - openai_messages.insert(0, responses.EasyInputMessageParam(role='system', content=instructions)) + system_prompt_count = sum( + 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) + ) + openai_messages.insert( + system_prompt_count, responses.EasyInputMessageParam(role='system', content=instructions) + ) instructions = OMIT if verbosity := model_settings.get('openai_text_verbosity'): From 8171571f6d866c4d9876cfe550906d715b2fc441 Mon Sep 17 00:00:00 2001 From: siddhantbhagat8 <103000056+siddhantbhagat8@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:22:32 -0800 Subject: [PATCH 2/3] find the right index in the provider messages list and not pydantic ai ones --- pydantic_ai_slim/pydantic_ai/models/cohere.py | 4 +--- pydantic_ai_slim/pydantic_ai/models/groq.py | 4 +--- pydantic_ai_slim/pydantic_ai/models/huggingface.py | 4 +--- pydantic_ai_slim/pydantic_ai/models/mistral.py | 4 +--- pydantic_ai_slim/pydantic_ai/models/openai.py | 9 ++------- 5 files changed, 6 insertions(+), 19 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/models/cohere.py b/pydantic_ai_slim/pydantic_ai/models/cohere.py index bfd0ab0838..bef81e3063 100644 --- a/pydantic_ai_slim/pydantic_ai/models/cohere.py +++ b/pydantic_ai_slim/pydantic_ai/models/cohere.py @@ -234,9 +234,6 @@ def _map_messages( ) -> list[ChatMessageV2]: """Just maps a `pydantic_ai.Message` to a `cohere.ChatMessageV2`.""" cohere_messages: list[ChatMessageV2] = [] - system_prompt_count = sum( - 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) - ) for message in messages: if isinstance(message, ModelRequest): cohere_messages.extend(self._map_user_message(message)) @@ -274,6 +271,7 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): + system_prompt_count = sum(1 for m in cohere_messages if isinstance(m, SystemChatMessageV2)) cohere_messages.insert(system_prompt_count, SystemChatMessageV2(role='system', content=instructions)) return cohere_messages diff --git a/pydantic_ai_slim/pydantic_ai/models/groq.py b/pydantic_ai_slim/pydantic_ai/models/groq.py index 06f8e8c0cb..acb84c90d2 100644 --- a/pydantic_ai_slim/pydantic_ai/models/groq.py +++ b/pydantic_ai_slim/pydantic_ai/models/groq.py @@ -395,9 +395,6 @@ def _map_messages( ) -> list[chat.ChatCompletionMessageParam]: """Just maps a `pydantic_ai.Message` to a `groq.types.ChatCompletionMessageParam`.""" groq_messages: list[chat.ChatCompletionMessageParam] = [] - system_prompt_count = sum( - 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) - ) for message in messages: if isinstance(message, ModelRequest): groq_messages.extend(self._map_user_message(message)) @@ -431,6 +428,7 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): + system_prompt_count = sum(1 for m in groq_messages if m.get('role') == 'system') groq_messages.insert( system_prompt_count, chat.ChatCompletionSystemMessageParam(role='system', content=instructions) ) diff --git a/pydantic_ai_slim/pydantic_ai/models/huggingface.py b/pydantic_ai_slim/pydantic_ai/models/huggingface.py index a855113289..1b9524c822 100644 --- a/pydantic_ai_slim/pydantic_ai/models/huggingface.py +++ b/pydantic_ai_slim/pydantic_ai/models/huggingface.py @@ -327,9 +327,6 @@ async def _map_messages( ) -> list[ChatCompletionInputMessage | ChatCompletionOutputMessage]: """Just maps a `pydantic_ai.Message` to a `huggingface_hub.ChatCompletionInputMessage`.""" hf_messages: list[ChatCompletionInputMessage | ChatCompletionOutputMessage] = [] - system_prompt_count = sum( - 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) - ) for message in messages: if isinstance(message, ModelRequest): async for item in self._map_user_message(message): @@ -364,6 +361,7 @@ async def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): + system_prompt_count = sum(1 for m in hf_messages if getattr(m, 'role', None) == 'system') hf_messages.insert(system_prompt_count, ChatCompletionInputMessage(content=instructions, role='system')) # type: ignore return hf_messages diff --git a/pydantic_ai_slim/pydantic_ai/models/mistral.py b/pydantic_ai_slim/pydantic_ai/models/mistral.py index 6fff29ca22..7493d63bea 100644 --- a/pydantic_ai_slim/pydantic_ai/models/mistral.py +++ b/pydantic_ai_slim/pydantic_ai/models/mistral.py @@ -528,9 +528,6 @@ def _map_messages( ) -> list[MistralMessages]: """Just maps a `pydantic_ai.Message` to a `MistralMessage`.""" mistral_messages: list[MistralMessages] = [] - system_prompt_count = sum( - 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) - ) for message in messages: if isinstance(message, ModelRequest): mistral_messages.extend(self._map_user_message(message)) @@ -560,6 +557,7 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): + system_prompt_count = sum(1 for m in mistral_messages if isinstance(m, MistralSystemMessage)) mistral_messages.insert(system_prompt_count, MistralSystemMessage(content=instructions)) # Post-process messages to insert fake assistant message after tool message if followed by user message diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index 5581448b28..1c831364d5 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -831,11 +831,7 @@ async def _map_messages( ) -> list[chat.ChatCompletionMessageParam]: """Just maps a `pydantic_ai.Message` to a `openai.types.ChatCompletionMessageParam`.""" openai_messages: list[chat.ChatCompletionMessageParam] = [] - system_prompt_count = 0 for message in messages: - for part in message.parts: - if isinstance(part, SystemPromptPart): - system_prompt_count += 1 if isinstance(message, ModelRequest): async for item in self._map_user_message(message): openai_messages.append(item) @@ -844,6 +840,7 @@ async def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): + system_prompt_count = sum(1 for m in openai_messages if m.get('role') == 'system') openai_messages.insert( system_prompt_count, chat.ChatCompletionSystemMessageParam(content=instructions, role='system') ) @@ -1319,9 +1316,7 @@ async def _responses_create( # noqa: C901 # > Response input messages must contain the word 'json' in some form to use 'text.format' of type 'json_object'. # Apparently they're only checking input messages for "JSON", not instructions. assert isinstance(instructions, str) - system_prompt_count = sum( - 1 for m in messages if isinstance(m, ModelRequest) for p in m.parts if isinstance(p, SystemPromptPart) - ) + system_prompt_count = sum(1 for m in openai_messages if m.get('role') == 'system') openai_messages.insert( system_prompt_count, responses.EasyInputMessageParam(role='system', content=instructions) ) From e980f9693daeb69186d832f0249915c4c8d256f3 Mon Sep 17 00:00:00 2001 From: siddhantbhagat8 <103000056+siddhantbhagat8@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:41:42 -0800 Subject: [PATCH 3/3] add test --- tests/test_agent.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_agent.py b/tests/test_agent.py index c912334434..ed038ccc3d 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -6313,3 +6313,43 @@ def llm(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse: ] ) assert run.all_messages_json().startswith(b'[{"parts":[{"content":"Hello",') + + +def test_instructions_inserted_after_system_prompt(): + """Tests that instructions are inserted after system prompts.""" + + agent = Agent('test') + + @agent.system_prompt + def system_prompt_1() -> str: + return 'System prompt 1' + + @agent.system_prompt + def system_prompt_2() -> str: + return 'System prompt 2' + + @agent.instructions + def instructions() -> str: + return 'Instructions' + + result = agent.run_sync('Hello') + assert result.all_messages()[0] == snapshot( + ModelRequest( + parts=[ + SystemPromptPart( + content='System prompt 1', + timestamp=IsNow(tz=timezone.utc), + ), + SystemPromptPart( + content='System prompt 2', + timestamp=IsNow(tz=timezone.utc), + ), + UserPromptPart( + content='Hello', + timestamp=IsNow(tz=timezone.utc), + ), + ], + instructions='Instructions', + run_id=IsStr(), + ) + )