From da24b501d75a1d3de17b3fdda15f235491bb733e Mon Sep 17 00:00:00 2001 From: g97iulio1609 Date: Sat, 28 Feb 2026 18:55:02 +0100 Subject: [PATCH] Python: fix: ChatHistoryTruncationReducer orphans TOOL role messages without FunctionResultContent The locate_safe_reduction_index backward scan only checked msg.items for FunctionCallContent/FunctionResultContent to avoid splitting tool call/result pairs. However, TOOL role messages that contain only text content (no FunctionResultContent in items) were not recognized as part of a tool pair, causing the truncation to split tool_calls from their tool responses. Fix: Include AuthorRole.TOOL check in contains_function_call_or_result so that any TOOL role message is treated as part of a tool call/result pair, regardless of its items content. Fixes #12708 --- .../chat_history_reducer_utils.py | 9 ++++- .../test_chat_history_reducer_utils.py | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py b/python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py index 459eac6a6e52..b8af00c84248 100644 --- a/python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py +++ b/python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py @@ -220,5 +220,12 @@ def extract_range( @experimental def contains_function_call_or_result(msg: ChatMessageContent) -> bool: - """Return True if the message has any function call or function result.""" + """Return True if the message has any function call or function result. + + Also returns True for TOOL role messages, which are always responses to + a preceding assistant message with tool_calls and must not be separated + from it. + """ + if msg.role == AuthorRole.TOOL: + return True return any(isinstance(item, (FunctionCallContent, FunctionResultContent)) for item in msg.items) diff --git a/python/tests/unit/contents/test_chat_history_reducer_utils.py b/python/tests/unit/contents/test_chat_history_reducer_utils.py index b2f6ac2e282f..fab01248dbc2 100644 --- a/python/tests/unit/contents/test_chat_history_reducer_utils.py +++ b/python/tests/unit/contents/test_chat_history_reducer_utils.py @@ -194,3 +194,41 @@ def test_locate_safe_reduction_index_high_offset(chat_messages_with_pairs): else: # It's fine if it returns None, meaning no valid safe reduction was found. pass + + +def test_locate_safe_reduction_index_tool_role_without_function_result_content(): + """Regression test: TOOL role messages without FunctionResultContent in items + must still be recognized as part of a tool call/result pair. + + This prevents orphaning tool results when the TOOL message only contains + text content (no FunctionResultContent item). + """ + msgs = [ + ChatMessageContent(role=AuthorRole.USER, content="Hello"), + ] + # Assistant with tool call + msg_call = ChatMessageContent(role=AuthorRole.ASSISTANT, content="") + msg_call.items.append(FunctionCallContent(id="call1", function_name="myTool", arguments={"x": 1})) + msgs.append(msg_call) + + # Tool result as role=TOOL but with plain text content only + msgs.append(ChatMessageContent(role=AuthorRole.TOOL, content="Tool result here")) + + msgs.append(ChatMessageContent(role=AuthorRole.USER, content="Thanks")) + msgs.append(ChatMessageContent(role=AuthorRole.ASSISTANT, content="You are welcome")) + + idx = locate_safe_reduction_index(msgs, target_count=3, threshold_count=0) + assert idx is not None + + # The tool call (index 1) must be included if tool result (index 2) is included + kept_indices = list(range(idx, len(msgs))) + has_tool_role = any(msgs[i].role == AuthorRole.TOOL for i in kept_indices) + has_tool_call = any( + any(isinstance(it, FunctionCallContent) for it in msgs[i].items) for i in kept_indices + ) + + if has_tool_role: + assert has_tool_call, ( + f"Tool result at index 2 was kept but tool call at index 1 was dropped. " + f"Kept indices: {kept_indices}, reduction index: {idx}" + )