Skip to content

Commit 45fe605

Browse files
Allaoua9Allaoua Benchikhmoonbox3
authored
Python: Fix issue with orphaned tool/tool_calls (#12923)
### Motivation and Context Tool/tool_calls are getting orphaned. Issue : #12708 How to reproduce : ```python from semantic_kernel.contents import ( AuthorRole, ChatMessageContent, FunctionCallContent, FunctionResultContent, ChatHistoryTruncationReducer, ) messages = [ ChatMessageContent(role=AuthorRole.SYSTEM, content="sys"), ChatMessageContent(role=AuthorRole.USER, content="user A"), ChatMessageContent( role=AuthorRole.ASSISTANT, items=[ FunctionCallContent( id="123", function_name="search", plugin_name="plugin", arguments={"q": "x"} ) ], ), ChatMessageContent( role=AuthorRole.TOOL, items=[ FunctionResultContent( id="123", function_name="search", plugin_name="plugin", result="RESULT" ) ], ), ChatMessageContent(role=AuthorRole.USER, content="user B"), ChatMessageContent(role=AuthorRole.ASSISTANT, content="done"), ] reducer = ChatHistoryTruncationReducer(target_count=3, threshold_count=0) reducer.messages = messages print("Before reduce:", [m.role for m in reducer.messages]) await reducer.reduce() print("After reduce:", [m.role for m in reducer.messages]) # After reduce: [<AuthorRole.TOOL: 'tool'>, <AuthorRole.USER: 'user'>, <AuthorRole.ASSISTANT: 'assistant'>] ``` ### Description Move backward as long as we are in a tool/tool_call island when reducing the messages. --------- Co-authored-by: Allaoua Benchikh <[email protected]> Co-authored-by: Evan Mattson <[email protected]> Co-authored-by: Evan Mattson <[email protected]>
1 parent 09fb007 commit 45fe605

File tree

2 files changed

+47
-4
lines changed

2 files changed

+47
-4
lines changed

python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,19 @@ def locate_safe_reduction_index(
9696
message_index = total_count - target_count
9797

9898
# Move backward to avoid cutting function calls / results
99-
# also skip over developer/system messages
99+
# Stop if we encounter developer/system or a non-call/result message
100100
while message_index >= offset_count:
101-
if history[message_index].role not in (AuthorRole.DEVELOPER, AuthorRole.SYSTEM):
101+
msg = history[message_index]
102+
if msg.role in (AuthorRole.DEVELOPER, AuthorRole.SYSTEM):
103+
break
104+
# If current is not a call/result, we've reached a safe boundary
105+
if not contains_function_call_or_result(msg):
102106
break
103-
if not contains_function_call_or_result(history[message_index]):
107+
# Avoid stepping back past a user message boundary when current is a call/result
108+
prev_idx = message_index - 1
109+
if (prev_idx < offset_count) or not contains_function_call_or_result(history[prev_idx]):
104110
break
111+
105112
message_index -= 1
106113

107114
# This is our initial target truncation index

python/tests/unit/contents/test_chat_history_truncation_reducer.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import pytest
44

55
from semantic_kernel.contents.chat_message_content import ChatMessageContent
6+
from semantic_kernel.contents.function_call_content import FunctionCallContent
7+
from semantic_kernel.contents.function_result_content import FunctionResultContent
68
from semantic_kernel.contents.history_reducer.chat_history_truncation_reducer import ChatHistoryTruncationReducer
79
from semantic_kernel.contents.utils.author_role import AuthorRole
810

@@ -18,6 +20,24 @@ def chat_messages():
1820
return msgs
1921

2022

23+
@pytest.fixture
24+
def chat_messages_with_tools():
25+
return [
26+
ChatMessageContent(role=AuthorRole.SYSTEM, content="System message."),
27+
ChatMessageContent(role=AuthorRole.USER, content="User message 1"),
28+
ChatMessageContent(
29+
role=AuthorRole.ASSISTANT,
30+
items=[FunctionCallContent(id="123", function_name="search", plugin_name="plugin", arguments={"q": "x"})],
31+
),
32+
ChatMessageContent(
33+
role=AuthorRole.TOOL,
34+
items=[FunctionResultContent(id="123", function_name="search", plugin_name="plugin", result="RESULT")],
35+
),
36+
ChatMessageContent(role=AuthorRole.USER, content="User message 2"),
37+
ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant message 2"),
38+
]
39+
40+
2141
def test_truncation_reducer_init():
2242
reducer = ChatHistoryTruncationReducer(target_count=5, threshold_count=2)
2343
assert reducer.target_count == 5
@@ -66,6 +86,22 @@ async def test_truncation_reducer_truncation(chat_messages):
6686
# We expect only 2 messages remain after truncation
6787
assert result is not None
6888
assert len(result) == 2
69-
# They should be the last 2 messages
89+
# They should be the last 4 messages while tool result is not orphaned
7090
assert result[0] == chat_messages[-2]
7191
assert result[1] == chat_messages[-1]
92+
93+
94+
async def test_truncation_reducer_truncation_with_tools(chat_messages_with_tools):
95+
# Force a smaller target so we do need to reduce
96+
reducer = ChatHistoryTruncationReducer(target_count=3, threshold_count=0)
97+
reducer.messages = chat_messages_with_tools
98+
result = await reducer.reduce()
99+
# We expect 4 messages remain after truncation
100+
# Tool results are not orphaned, so we expect to keep them
101+
assert result is not None
102+
assert len(result) == 4
103+
# They should be the last 4 messages
104+
assert result[0] == chat_messages_with_tools[-4]
105+
assert result[1] == chat_messages_with_tools[-3]
106+
assert result[2] == chat_messages_with_tools[-2]
107+
assert result[3] == chat_messages_with_tools[-1]

0 commit comments

Comments
 (0)