Skip to content

Commit f034ed1

Browse files
giulio-leonegiulio-leonemoonbox3Copilot
authored
Python: fix: ChatHistoryTruncationReducer orphans TOOL role messages (#13608)
## Summary Fixes #12708 `ChatHistoryTruncationReducer.reduce()` can orphan `TOOL` role messages by truncating the preceding `assistant` message that contains the `tool_calls`, causing OpenAI to reject the history with: ``` messages with role 'tool' must be a response to a preceding message with 'tool_calls' ``` ## Root Cause `locate_safe_reduction_index()` uses `contains_function_call_or_result()` to detect tool-related messages during its backward scan. This function only checks `msg.items` for `FunctionCallContent`/`FunctionResultContent` instances. However, `TOOL` role messages can contain only text content (no `FunctionResultContent` in `items`), which causes the backward scan to treat them as regular messages. The truncation point then lands between the `tool_calls` assistant message and its `TOOL` responses, orphaning the tool results. ## Fix Added `AuthorRole.TOOL` check to `contains_function_call_or_result()`: ```python if msg.role == AuthorRole.TOOL: return True ``` This ensures **any** `TOOL` role message is recognized as part of a tool call/result pair, regardless of whether it has `FunctionResultContent` in its `items`. ## Test Added `test_locate_safe_reduction_index_tool_role_without_function_result_content` that verifies TOOL role messages with only text content are not separated from their tool call. --------- Co-authored-by: giulio-leone <giulio.leone@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ed2bb2 commit f034ed1

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,5 +237,12 @@ def extract_range(
237237

238238
@experimental
239239
def contains_function_call_or_result(msg: ChatMessageContent) -> bool:
240-
"""Return True if the message has any function call or function result."""
240+
"""Return True if the message has any function call or function result.
241+
242+
Also returns True for TOOL role messages, which are always responses to
243+
a preceding assistant message with tool_calls and must not be separated
244+
from it.
245+
"""
246+
if msg.role == AuthorRole.TOOL:
247+
return True
241248
return any(isinstance(item, (FunctionCallContent, FunctionResultContent)) for item in msg.items)

python/tests/unit/contents/test_chat_history_reducer_utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,39 @@ def test_locate_safe_reduction_index_high_offset(chat_messages_with_pairs):
194194
else:
195195
# It's fine if it returns None, meaning no valid safe reduction was found.
196196
pass
197+
198+
199+
def test_locate_safe_reduction_index_tool_role_without_function_result_content():
200+
"""Regression test: TOOL role messages without FunctionResultContent in items
201+
must still be recognized as part of a tool call/result pair.
202+
203+
This prevents orphaning tool results when the TOOL message only contains
204+
text content (no FunctionResultContent item).
205+
"""
206+
msgs = [
207+
ChatMessageContent(role=AuthorRole.USER, content="Hello"),
208+
]
209+
# Assistant with tool call
210+
msg_call = ChatMessageContent(role=AuthorRole.ASSISTANT, content="")
211+
msg_call.items.append(FunctionCallContent(id="call1", function_name="myTool", arguments={"x": 1}))
212+
msgs.append(msg_call)
213+
214+
# Tool result as role=TOOL but with plain text content only
215+
msgs.append(ChatMessageContent(role=AuthorRole.TOOL, content="Tool result here"))
216+
217+
msgs.append(ChatMessageContent(role=AuthorRole.USER, content="Thanks"))
218+
msgs.append(ChatMessageContent(role=AuthorRole.ASSISTANT, content="You are welcome"))
219+
220+
idx = locate_safe_reduction_index(msgs, target_count=3, threshold_count=0)
221+
assert idx is not None
222+
223+
# The tool call (index 1) must be included if tool result (index 2) is included
224+
kept_indices = list(range(idx, len(msgs)))
225+
has_tool_role = any(msgs[i].role == AuthorRole.TOOL for i in kept_indices)
226+
has_tool_call = any(any(isinstance(it, FunctionCallContent) for it in msgs[i].items) for i in kept_indices)
227+
228+
if has_tool_role:
229+
assert has_tool_call, (
230+
f"Tool result at index 2 was kept but tool call at index 1 was dropped. "
231+
f"Kept indices: {kept_indices}, reduction index: {idx}"
232+
)

0 commit comments

Comments
 (0)