From 8c8a29ee301e76f3726d70dbbda5a1d5c3f89977 Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 09:53:31 -0400 Subject: [PATCH 1/9] test: add regression guard for tool-return stub merging Add test to prevent tool-return stubs from being incorrectly merged into subsequent user requests during message history cleaning. - Guard against regression introduced in commit b26a6872f - Verify tool-return parts remain in separate message entries - Ensure cleaning preserves distinct message boundaries between tool results and user prompts - Validate part types remain correct after history cleaning --- tests/test_history_processor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_history_processor.py b/tests/test_history_processor.py index 54d38935d2..34299e97e6 100644 --- a/tests/test_history_processor.py +++ b/tests/test_history_processor.py @@ -801,3 +801,28 @@ def __call__(self, _: RunContext, messages: list[ModelMessage]) -> list[ModelMes ] ) assert result.new_messages() == result.all_messages()[-2:] + +def test_clean_message_history_keeps_tool_stub_separate(): + """Regression guard for b26a6872f that merged tool-return stubs into the next user request.""" + + # TODO: imports should get moved to the top whenever we open P/R + from pydantic_ai.messages import ToolReturnPart + from pydantic_ai._agent_graph import _clean_message_history + + tool_stub = ModelRequest( + parts=[ + ToolReturnPart( + tool_name='summarize', + content='summaries galore', + tool_call_id='call-1', + ) + ] + ) + user_request = ModelRequest(parts=[UserPromptPart(content='fresh prompt')]) + + cleaned = _clean_message_history([tool_stub, user_request]) + + assert len(cleaned[0].parts) == 1, 'tool-return part started as unique and should remain unique' + assert len(cleaned) == 2, 'tool-return stubs must remain separate from subsequent user prompts' + assert isinstance(cleaned[0].parts[0], ToolReturnPart) + assert isinstance(cleaned[1].parts[0], UserPromptPart) From feba0914068986e9add2aff2329e8487cdab0191 Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 13:58:03 -0400 Subject: [PATCH 2/9] fix: prevent merging non-stub ModelRequest into stub-only requests - Add logic to identify "stub" ModelRequests containing only ToolReturnPart or RetryPromptPart - Prevent merging non-stub requests into stub-only requests to maintain proper message structure - Allow merging stub-into-stub or non-stub-into-non-stub requests when instructions match - Refactor merge conditions for better clarity and correctness of message history handling --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 41 ++++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index d7a54c5c71..9a433cf5e9 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -1169,32 +1169,41 @@ async def _process_message_history( def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_messages.ModelMessage]: """Clean the message history by merging consecutive messages of the same type.""" clean_messages: list[_messages.ModelMessage] = [] + # Add parts to a set to ensure no duplication + parts_set = set() for message in messages: last_message = clean_messages[-1] if len(clean_messages) > 0 else None if isinstance(message, _messages.ModelRequest): - if ( - last_message - and isinstance(last_message, _messages.ModelRequest) - # Requests can only be merged if they have the same instructions - and ( + if last_message and isinstance(last_message, _messages.ModelRequest): + same_instructions = ( not last_message.instructions or not message.instructions or last_message.instructions == message.instructions ) - ): - parts = [*last_message.parts, *message.parts] - parts.sort( - # Tool return parts always need to be at the start - key=lambda x: 0 if isinstance(x, _messages.ToolReturnPart | _messages.RetryPromptPart) else 1 + last_is_stub = all( + isinstance(part, _messages.ToolReturnPart | _messages.RetryPromptPart) + for part in last_message.parts ) - merged_message = _messages.ModelRequest( - parts=parts, - instructions=last_message.instructions or message.instructions, + message_is_stub = all( + isinstance(part, _messages.ToolReturnPart | _messages.RetryPromptPart) + for part in message.parts ) - clean_messages[-1] = merged_message - else: - clean_messages.append(message) + + if same_instructions and (not last_is_stub or message_is_stub): + parts = [*last_message.parts, *message.parts] + parts.sort( + # Tool return parts always need to be at the start + key=lambda x: 0 if isinstance(x, _messages.ToolReturnPart | _messages.RetryPromptPart) else 1 + ) + merged_message = _messages.ModelRequest( + parts=parts, + instructions=last_message.instructions or message.instructions, + ) + clean_messages[-1] = merged_message + continue + + clean_messages.append(message) elif isinstance(message, _messages.ModelResponse): # pragma: no branch if ( last_message From 32a5c339f062a0c98a5fb2b6d3347922d7d0d78e Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 14:29:33 -0400 Subject: [PATCH 3/9] fix: prevent duplicate message parts in conversation history - Add deduplication logic to filter duplicate parts within messages - Use hash-based tracking to identify and skip duplicate content - Ensure backward compatibility with existing conversations in "bad state" - Initialize parts_set to track previously seen message parts - Append only unique parts to the final message list --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index 9a433cf5e9..98239f7946 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -5,6 +5,8 @@ import inspect from asyncio import Task from collections import defaultdict, deque + +import pydantic_ai.messages from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar @@ -1220,4 +1222,16 @@ def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_mess clean_messages[-1] = merged_message else: clean_messages.append(message) - return clean_messages + + # This is a special filter that ensures old conversations in the "bad state" can be used + final_result = [] + for message in clean_messages: + parts = [] + for idx, part in enumerate(message.parts): + part_hash = hash(str(part)) + if part_hash not in parts_set: + parts_set.add(part_hash) + parts.append(part) + continue + final_result.append(message) + return final_result From 9cbd987a0f682c48de9c768225c04635bb798c15 Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 14:58:19 -0400 Subject: [PATCH 4/9] Set deduped parts to message.parts in filter --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index 98239f7946..65866bf1a2 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -1233,5 +1233,6 @@ def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_mess parts_set.add(part_hash) parts.append(part) continue + message.parts = parts final_result.append(message) return final_result From 4746ecedbb43c05559215b4fc3370d89d58eb3fb Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 15:45:35 -0400 Subject: [PATCH 5/9] Update Snapshots --- .../test_known_model_names.yaml | 18 +++++++-------- tests/models/test_google.py | 13 +++++++++-- tests/models/test_groq.py | 1 - tests/test_a2a.py | 22 ++++++++++++++----- tests/test_agent.py | 15 ++++++++----- 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/tests/models/cassettes/test_model_names/test_known_model_names.yaml b/tests/models/cassettes/test_model_names/test_known_model_names.yaml index 19c06a8929..51cca725aa 100644 --- a/tests/models/cassettes/test_model_names/test_known_model_names.yaml +++ b/tests/models/cassettes/test_model_names/test_known_model_names.yaml @@ -109,39 +109,39 @@ interactions: parsed_body: data: - created: 0 - id: llama-4-scout-17b-16e-instruct + id: llama3.1-8b object: model owned_by: Cerebras - created: 0 - id: qwen-3-32b + id: qwen-3-235b-a22b-instruct-2507 object: model owned_by: Cerebras - created: 0 - id: llama-3.3-70b + id: llama-4-scout-17b-16e-instruct object: model owned_by: Cerebras - created: 0 - id: qwen-3-235b-a22b-instruct-2507 + id: llama-4-maverick-17b-128e-instruct object: model owned_by: Cerebras - created: 0 - id: llama-4-maverick-17b-128e-instruct + id: llama-3.3-70b object: model owned_by: Cerebras - created: 0 - id: qwen-3-coder-480b + id: qwen-3-235b-a22b-thinking-2507 object: model owned_by: Cerebras - created: 0 - id: gpt-oss-120b + id: qwen-3-coder-480b object: model owned_by: Cerebras - created: 0 - id: qwen-3-235b-a22b-thinking-2507 + id: gpt-oss-120b object: model owned_by: Cerebras - created: 0 - id: llama3.1-8b + id: qwen-3-32b object: model owned_by: Cerebras object: list diff --git a/tests/models/test_google.py b/tests/models/test_google.py index ce461e2810..80977d12d7 100644 --- a/tests/models/test_google.py +++ b/tests/models/test_google.py @@ -1485,8 +1485,6 @@ async def test_google_model_receive_web_search_history_from_another_provider( TextPart, TextPart, TextPart, - TextPart, - TextPart, ], [UserPromptPart], [TextPart], @@ -2600,6 +2598,7 @@ async def test_google_image_generation(allow_model_requests: None, google_provid BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='8a7952', identifier='8a7952', ) ) @@ -2620,6 +2619,7 @@ async def test_google_image_generation(allow_model_requests: None, google_provid content=BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='8a7952', identifier='8a7952', ) ), @@ -2644,6 +2644,7 @@ async def test_google_image_generation(allow_model_requests: None, google_provid BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='7d173c', identifier='7d173c', ) ) @@ -2664,6 +2665,7 @@ async def test_google_image_generation(allow_model_requests: None, google_provid content=BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='7d173c', identifier='7d173c', ) ), @@ -2693,6 +2695,7 @@ async def test_google_image_generation_stream(allow_model_requests: None, google BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='9ff9cc', identifier='9ff9cc', ) ) @@ -2710,6 +2713,7 @@ async def test_google_image_generation_stream(allow_model_requests: None, google BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='2af2a7', identifier='2af2a7', ) ) @@ -2730,6 +2734,7 @@ async def test_google_image_generation_stream(allow_model_requests: None, google content=BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='2af2a7', identifier='2af2a7', ) ), @@ -2758,6 +2763,7 @@ async def test_google_image_generation_stream(allow_model_requests: None, google content=BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='2af2a7', identifier='2af2a7', ) ), @@ -2796,6 +2802,7 @@ async def test_google_image_generation_with_text(allow_model_requests: None, goo content=BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='00f2af', identifier=IsStr(), ) ), @@ -2831,6 +2838,7 @@ async def test_google_image_or_text_output(allow_model_requests: None, google_pr BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='f82faf', identifier='f82faf', ) ) @@ -2849,6 +2857,7 @@ async def test_google_image_and_text_output(allow_model_requests: None, google_p BinaryImage( data=IsBytes(), media_type='image/png', + _identifier='67b12f', identifier='67b12f', ) ] diff --git a/tests/models/test_groq.py b/tests/models/test_groq.py index a24089cf7c..d723361d8b 100644 --- a/tests/models/test_groq.py +++ b/tests/models/test_groq.py @@ -5205,7 +5205,6 @@ async def get_something_by_name(name: str) -> str: ), ModelResponse( parts=[ - TextPart(content=''), ThinkingPart(content='We need to call with correct param: name. Use a placeholder name.'), ToolCallPart( tool_name='get_something_by_name', diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 93e56f12c0..394f7f4839 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -1,6 +1,7 @@ import uuid import anyio +import datetime import httpx import pytest from asgi_lifespan import LifespanManager @@ -621,11 +622,22 @@ def track_messages(messages: list[ModelMessage], info: AgentInfo) -> ModelRespon ToolReturnPart( tool_name='final_result', content='Final result processed.', - tool_call_id=IsStr(), - timestamp=IsDatetime(), - ), - UserPromptPart(content='Second message', timestamp=IsDatetime()), - ], + tool_call_id='pyd_ai_f535bc4dac9449329e63ff582da5778e', + timestamp=datetime.datetime( + 2025, 10, 11, 19, 35, 49, 748720, tzinfo=datetime.timezone.utc + ), + ) + ] + ), + ModelRequest( + parts=[ + UserPromptPart( + content='Second message', + timestamp=datetime.datetime( + 2025, 10, 11, 19, 35, 49, 854888, tzinfo=datetime.timezone.utc + ), + ) + ] ), ] ) diff --git a/tests/test_agent.py b/tests/test_agent.py index c8beb08312..ab401a2654 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3616,6 +3616,7 @@ def get_image() -> BinaryContent: BinaryContent( data=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178\x00\x00\x00\x00IEND\xaeB`\x82', media_type='image/png', + _identifier='image_id_1', identifier='image_id_1', ), ], @@ -3660,13 +3661,15 @@ def get_files(): UserPromptPart( content=[ 'This is file img_001:', - ImageUrl(url='https://example.com/image.jpg', identifier='img_001'), + ImageUrl(url='https://example.com/image.jpg', _identifier='img_001', identifier='img_001'), 'This is file vid_002:', - VideoUrl(url='https://example.com/video.mp4', identifier='vid_002'), + VideoUrl(url='https://example.com/video.mp4', _identifier='vid_002', identifier='vid_002'), 'This is file aud_003:', - AudioUrl(url='https://example.com/audio.mp3', identifier='aud_003'), + AudioUrl(url='https://example.com/audio.mp3', _identifier='aud_003', identifier='aud_003'), 'This is file doc_004:', - DocumentUrl(url='https://example.com/document.pdf', identifier='doc_004'), + DocumentUrl( + url='https://example.com/document.pdf', _identifier='doc_004', identifier='doc_004' + ), ], timestamp=IsNow(tz=timezone.utc), ), @@ -5500,7 +5503,7 @@ def roll_dice() -> int: ] ), ModelResponse( - parts=[ToolCallPart(tool_name='roll_dice', args={}, tool_call_id='pyd_ai_tool_call_id__roll_dice')], + parts=[], usage=RequestUsage(input_tokens=66, output_tokens=8), model_name='function:llm:', timestamp=IsDatetime(), @@ -5523,7 +5526,7 @@ def roll_dice() -> int: tool_call_id='pyd_ai_tool_call_id__final_result', ) ], - usage=RequestUsage(input_tokens=67, output_tokens=12), + usage=RequestUsage(input_tokens=67, output_tokens=10), model_name='function:llm:', timestamp=IsDatetime(), ), From 5c4981392c15b1cb66bb845230236cfe23bfdb6e Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 15:55:50 -0400 Subject: [PATCH 6/9] Revert "Update Snapshots" This reverts commit 4746ecedbb43c05559215b4fc3370d89d58eb3fb. --- .../test_known_model_names.yaml | 18 +++++++-------- tests/models/test_google.py | 13 ++--------- tests/models/test_groq.py | 1 + tests/test_a2a.py | 22 +++++-------------- tests/test_agent.py | 15 +++++-------- 5 files changed, 23 insertions(+), 46 deletions(-) diff --git a/tests/models/cassettes/test_model_names/test_known_model_names.yaml b/tests/models/cassettes/test_model_names/test_known_model_names.yaml index 51cca725aa..19c06a8929 100644 --- a/tests/models/cassettes/test_model_names/test_known_model_names.yaml +++ b/tests/models/cassettes/test_model_names/test_known_model_names.yaml @@ -109,39 +109,39 @@ interactions: parsed_body: data: - created: 0 - id: llama3.1-8b + id: llama-4-scout-17b-16e-instruct object: model owned_by: Cerebras - created: 0 - id: qwen-3-235b-a22b-instruct-2507 + id: qwen-3-32b object: model owned_by: Cerebras - created: 0 - id: llama-4-scout-17b-16e-instruct + id: llama-3.3-70b object: model owned_by: Cerebras - created: 0 - id: llama-4-maverick-17b-128e-instruct + id: qwen-3-235b-a22b-instruct-2507 object: model owned_by: Cerebras - created: 0 - id: llama-3.3-70b + id: llama-4-maverick-17b-128e-instruct object: model owned_by: Cerebras - created: 0 - id: qwen-3-235b-a22b-thinking-2507 + id: qwen-3-coder-480b object: model owned_by: Cerebras - created: 0 - id: qwen-3-coder-480b + id: gpt-oss-120b object: model owned_by: Cerebras - created: 0 - id: gpt-oss-120b + id: qwen-3-235b-a22b-thinking-2507 object: model owned_by: Cerebras - created: 0 - id: qwen-3-32b + id: llama3.1-8b object: model owned_by: Cerebras object: list diff --git a/tests/models/test_google.py b/tests/models/test_google.py index 80977d12d7..ce461e2810 100644 --- a/tests/models/test_google.py +++ b/tests/models/test_google.py @@ -1485,6 +1485,8 @@ async def test_google_model_receive_web_search_history_from_another_provider( TextPart, TextPart, TextPart, + TextPart, + TextPart, ], [UserPromptPart], [TextPart], @@ -2598,7 +2600,6 @@ async def test_google_image_generation(allow_model_requests: None, google_provid BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='8a7952', identifier='8a7952', ) ) @@ -2619,7 +2620,6 @@ async def test_google_image_generation(allow_model_requests: None, google_provid content=BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='8a7952', identifier='8a7952', ) ), @@ -2644,7 +2644,6 @@ async def test_google_image_generation(allow_model_requests: None, google_provid BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='7d173c', identifier='7d173c', ) ) @@ -2665,7 +2664,6 @@ async def test_google_image_generation(allow_model_requests: None, google_provid content=BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='7d173c', identifier='7d173c', ) ), @@ -2695,7 +2693,6 @@ async def test_google_image_generation_stream(allow_model_requests: None, google BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='9ff9cc', identifier='9ff9cc', ) ) @@ -2713,7 +2710,6 @@ async def test_google_image_generation_stream(allow_model_requests: None, google BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='2af2a7', identifier='2af2a7', ) ) @@ -2734,7 +2730,6 @@ async def test_google_image_generation_stream(allow_model_requests: None, google content=BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='2af2a7', identifier='2af2a7', ) ), @@ -2763,7 +2758,6 @@ async def test_google_image_generation_stream(allow_model_requests: None, google content=BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='2af2a7', identifier='2af2a7', ) ), @@ -2802,7 +2796,6 @@ async def test_google_image_generation_with_text(allow_model_requests: None, goo content=BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='00f2af', identifier=IsStr(), ) ), @@ -2838,7 +2831,6 @@ async def test_google_image_or_text_output(allow_model_requests: None, google_pr BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='f82faf', identifier='f82faf', ) ) @@ -2857,7 +2849,6 @@ async def test_google_image_and_text_output(allow_model_requests: None, google_p BinaryImage( data=IsBytes(), media_type='image/png', - _identifier='67b12f', identifier='67b12f', ) ] diff --git a/tests/models/test_groq.py b/tests/models/test_groq.py index d723361d8b..a24089cf7c 100644 --- a/tests/models/test_groq.py +++ b/tests/models/test_groq.py @@ -5205,6 +5205,7 @@ async def get_something_by_name(name: str) -> str: ), ModelResponse( parts=[ + TextPart(content=''), ThinkingPart(content='We need to call with correct param: name. Use a placeholder name.'), ToolCallPart( tool_name='get_something_by_name', diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 394f7f4839..93e56f12c0 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -1,7 +1,6 @@ import uuid import anyio -import datetime import httpx import pytest from asgi_lifespan import LifespanManager @@ -622,22 +621,11 @@ def track_messages(messages: list[ModelMessage], info: AgentInfo) -> ModelRespon ToolReturnPart( tool_name='final_result', content='Final result processed.', - tool_call_id='pyd_ai_f535bc4dac9449329e63ff582da5778e', - timestamp=datetime.datetime( - 2025, 10, 11, 19, 35, 49, 748720, tzinfo=datetime.timezone.utc - ), - ) - ] - ), - ModelRequest( - parts=[ - UserPromptPart( - content='Second message', - timestamp=datetime.datetime( - 2025, 10, 11, 19, 35, 49, 854888, tzinfo=datetime.timezone.utc - ), - ) - ] + tool_call_id=IsStr(), + timestamp=IsDatetime(), + ), + UserPromptPart(content='Second message', timestamp=IsDatetime()), + ], ), ] ) diff --git a/tests/test_agent.py b/tests/test_agent.py index ab401a2654..c8beb08312 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -3616,7 +3616,6 @@ def get_image() -> BinaryContent: BinaryContent( data=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178\x00\x00\x00\x00IEND\xaeB`\x82', media_type='image/png', - _identifier='image_id_1', identifier='image_id_1', ), ], @@ -3661,15 +3660,13 @@ def get_files(): UserPromptPart( content=[ 'This is file img_001:', - ImageUrl(url='https://example.com/image.jpg', _identifier='img_001', identifier='img_001'), + ImageUrl(url='https://example.com/image.jpg', identifier='img_001'), 'This is file vid_002:', - VideoUrl(url='https://example.com/video.mp4', _identifier='vid_002', identifier='vid_002'), + VideoUrl(url='https://example.com/video.mp4', identifier='vid_002'), 'This is file aud_003:', - AudioUrl(url='https://example.com/audio.mp3', _identifier='aud_003', identifier='aud_003'), + AudioUrl(url='https://example.com/audio.mp3', identifier='aud_003'), 'This is file doc_004:', - DocumentUrl( - url='https://example.com/document.pdf', _identifier='doc_004', identifier='doc_004' - ), + DocumentUrl(url='https://example.com/document.pdf', identifier='doc_004'), ], timestamp=IsNow(tz=timezone.utc), ), @@ -5503,7 +5500,7 @@ def roll_dice() -> int: ] ), ModelResponse( - parts=[], + parts=[ToolCallPart(tool_name='roll_dice', args={}, tool_call_id='pyd_ai_tool_call_id__roll_dice')], usage=RequestUsage(input_tokens=66, output_tokens=8), model_name='function:llm:', timestamp=IsDatetime(), @@ -5526,7 +5523,7 @@ def roll_dice() -> int: tool_call_id='pyd_ai_tool_call_id__final_result', ) ], - usage=RequestUsage(input_tokens=67, output_tokens=10), + usage=RequestUsage(input_tokens=67, output_tokens=12), model_name='function:llm:', timestamp=IsDatetime(), ), From f4886b47c11a937ed4bc468a0f9562be5e04d887 Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 16:08:33 -0400 Subject: [PATCH 7/9] refactor: remove duplicate part filtering from message history cleanup - Remove unnecessary hash-based deduplication logic that tracked parts across messages - Simplify _clean_message_history to only merge consecutive messages of same type - Remove unused import of pydantic_ai.messages module - Fix import ordering in test file to follow conventions The hash-based filtering was overly complex and potentially masking underlying issues with message construction. Message merging alone is sufficient for cleaning the history. --- pydantic_ai_slim/pydantic_ai/_agent_graph.py | 21 ++------------------ tests/test_history_processor.py | 3 ++- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_agent_graph.py b/pydantic_ai_slim/pydantic_ai/_agent_graph.py index 65866bf1a2..13dc4d6b15 100644 --- a/pydantic_ai_slim/pydantic_ai/_agent_graph.py +++ b/pydantic_ai_slim/pydantic_ai/_agent_graph.py @@ -5,8 +5,6 @@ import inspect from asyncio import Task from collections import defaultdict, deque - -import pydantic_ai.messages from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar @@ -1172,7 +1170,6 @@ def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_mess """Clean the message history by merging consecutive messages of the same type.""" clean_messages: list[_messages.ModelMessage] = [] # Add parts to a set to ensure no duplication - parts_set = set() for message in messages: last_message = clean_messages[-1] if len(clean_messages) > 0 else None @@ -1188,8 +1185,7 @@ def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_mess for part in last_message.parts ) message_is_stub = all( - isinstance(part, _messages.ToolReturnPart | _messages.RetryPromptPart) - for part in message.parts + isinstance(part, _messages.ToolReturnPart | _messages.RetryPromptPart) for part in message.parts ) if same_instructions and (not last_is_stub or message_is_stub): @@ -1222,17 +1218,4 @@ def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_mess clean_messages[-1] = merged_message else: clean_messages.append(message) - - # This is a special filter that ensures old conversations in the "bad state" can be used - final_result = [] - for message in clean_messages: - parts = [] - for idx, part in enumerate(message.parts): - part_hash = hash(str(part)) - if part_hash not in parts_set: - parts_set.add(part_hash) - parts.append(part) - continue - message.parts = parts - final_result.append(message) - return final_result + return clean_messages diff --git a/tests/test_history_processor.py b/tests/test_history_processor.py index 34299e97e6..a1d1a1fa0e 100644 --- a/tests/test_history_processor.py +++ b/tests/test_history_processor.py @@ -802,12 +802,13 @@ def __call__(self, _: RunContext, messages: list[ModelMessage]) -> list[ModelMes ) assert result.new_messages() == result.all_messages()[-2:] + def test_clean_message_history_keeps_tool_stub_separate(): """Regression guard for b26a6872f that merged tool-return stubs into the next user request.""" # TODO: imports should get moved to the top whenever we open P/R - from pydantic_ai.messages import ToolReturnPart from pydantic_ai._agent_graph import _clean_message_history + from pydantic_ai.messages import ToolReturnPart tool_stub = ModelRequest( parts=[ From ebfc2816fcfabed6bfa11d6776533505bcd1fa8d Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 16:16:25 -0400 Subject: [PATCH 8/9] test: suppress pyright error for internal import in regression test Add pyright ignore comment to _clean_message_history import to suppress type checking warnings for this internal API usage in the test suite. --- tests/test_history_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_history_processor.py b/tests/test_history_processor.py index a1d1a1fa0e..78660ef517 100644 --- a/tests/test_history_processor.py +++ b/tests/test_history_processor.py @@ -807,7 +807,7 @@ def test_clean_message_history_keeps_tool_stub_separate(): """Regression guard for b26a6872f that merged tool-return stubs into the next user request.""" # TODO: imports should get moved to the top whenever we open P/R - from pydantic_ai._agent_graph import _clean_message_history + from pydantic_ai._agent_graph import _clean_message_history # pyright: ignore from pydantic_ai.messages import ToolReturnPart tool_stub = ModelRequest( From e7f5297d395ac7fe41bfa57fd35fe05ffa9ba59a Mon Sep 17 00:00:00 2001 From: Mike Pfaffenberger Date: Sat, 11 Oct 2025 16:29:54 -0400 Subject: [PATCH 9/9] Fix structure in a2a test --- tests/test_a2a.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_a2a.py b/tests/test_a2a.py index 93e56f12c0..048f8a1e8f 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -623,10 +623,12 @@ def track_messages(messages: list[ModelMessage], info: AgentInfo) -> ModelRespon content='Final result processed.', tool_call_id=IsStr(), timestamp=IsDatetime(), - ), - UserPromptPart(content='Second message', timestamp=IsDatetime()), + ) ], ), + ModelRequest( + parts=[UserPromptPart(content='Second message', timestamp=IsDatetime())], + ), ] )