Repository navigation
Python: fix(python): preserve non-adjacent tool pairs during history truncation #14658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
hylin (linhongyu510)
wants to merge
2
commits into
microsoft:main
Choose a base branch
from
linhongyu510:fix/nonadjacent-tool-pair-truncation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
49 changes: 49 additions & 0 deletions
49
python/tests/unit/contents/test_chat_history_multi_result_pairing.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| # Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| from semantic_kernel.contents.chat_message_content import ChatMessageContent | ||
| from semantic_kernel.contents.function_call_content import FunctionCallContent | ||
| from semantic_kernel.contents.function_result_content import FunctionResultContent | ||
| from semantic_kernel.contents.history_reducer.chat_history_reducer_utils import get_call_result_pairs | ||
| from semantic_kernel.contents.utils.author_role import AuthorRole | ||
|
|
||
|
|
||
| def _assistant_with(calls): | ||
| m = ChatMessageContent(role=AuthorRole.ASSISTANT, content="") | ||
| for cid in calls: | ||
| m.items.append(FunctionCallContent(id=cid, function_name="f", plugin_name="p", arguments={})) | ||
| return m | ||
|
|
||
|
|
||
| def _tool_with(results): | ||
| m = ChatMessageContent(role=AuthorRole.TOOL, content="") | ||
| for rid in results: | ||
| m.items.append(FunctionResultContent(id=rid, function_name="f", plugin_name="p", result="r")) | ||
| return m | ||
|
|
||
|
|
||
| def test_multiple_results_in_one_tool_message_are_all_paired(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="u1"), | ||
| _assistant_with(["c1"]), | ||
| _tool_with(["c1"]), | ||
| ChatMessageContent(role=AuthorRole.USER, content="u2"), | ||
| _assistant_with(["c2", "c3"]), | ||
| _tool_with(["c2", "c3"]), | ||
| ] | ||
| pairs = get_call_result_pairs(history) | ||
| call_indices = [c for c, r in pairs] | ||
| assert 4 in call_indices, f"Expected call at msg4 in pairs, got {pairs}" | ||
| assert call_indices.count(4) == 2, f"Expected two pairs for msg4 (c2,c3), got {pairs}" | ||
|
|
||
|
|
||
| def test_multiple_results_across_separate_messages(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="u1"), | ||
| _assistant_with(["c1", "c2"]), | ||
| ChatMessageContent(role=AuthorRole.ASSISTANT, content="text"), | ||
| _tool_with(["c1"]), | ||
| _tool_with(["c2"]), | ||
| ] | ||
| pairs = get_call_result_pairs(history) | ||
| assert (1, 3) in pairs, f"Expected (1,3), got {pairs}" | ||
| assert (1, 4) in pairs, f"Expected (1,4), got {pairs}" |
201 changes: 201 additions & 0 deletions
201
python/tests/unit/contents/test_chat_history_truncation_pair_invariant.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| # Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| from semantic_kernel.contents.chat_message_content import ChatMessageContent | ||
| from semantic_kernel.contents.function_call_content import FunctionCallContent | ||
| from semantic_kernel.contents.function_result_content import FunctionResultContent | ||
| from semantic_kernel.contents.history_reducer.chat_history_reducer_utils import locate_safe_reduction_index | ||
| from semantic_kernel.contents.history_reducer.chat_history_truncation_reducer import ChatHistoryTruncationReducer | ||
| from semantic_kernel.contents.utils.author_role import AuthorRole | ||
|
|
||
|
|
||
| def _assistant_call(call_id: str, function_name: str = "search") -> ChatMessageContent: | ||
| message = ChatMessageContent(role=AuthorRole.ASSISTANT, content="") | ||
| message.items.append( | ||
| FunctionCallContent(id=call_id, function_name=function_name, plugin_name="plugin", arguments={"q": call_id}) | ||
| ) | ||
| return message | ||
|
|
||
|
|
||
| def _tool_result(call_id: str, function_name: str = "search") -> ChatMessageContent: | ||
| message = ChatMessageContent(role=AuthorRole.TOOL, content="") | ||
| message.items.append( | ||
| FunctionResultContent(id=call_id, function_name=function_name, plugin_name="plugin", result=f"result-{call_id}") | ||
| ) | ||
| return message | ||
|
|
||
|
|
||
| def _has_call(history: list[ChatMessageContent], call_id: str) -> bool: | ||
| return any( | ||
| isinstance(item, FunctionCallContent) and item.id == call_id for message in history for item in message.items | ||
| ) | ||
|
|
||
|
|
||
| def _has_result(history: list[ChatMessageContent], call_id: str) -> bool: | ||
| return any( | ||
| isinstance(item, FunctionResultContent) and item.id == call_id for message in history for item in message.items | ||
| ) | ||
|
|
||
|
|
||
| async def test_truncation_keeps_nonadjacent_function_call_with_its_result(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 1"), | ||
| _assistant_call("call-1"), | ||
| _tool_result("call-1"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 2"), | ||
| ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant text between tool rounds."), | ||
| _assistant_call("call-2"), | ||
| ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant text after tool call."), | ||
| _tool_result("call-2"), | ||
| ] | ||
|
|
||
| assert history[1].role == AuthorRole.ASSISTANT | ||
| assert history[2].role == AuthorRole.TOOL | ||
| assert history[5].role == AuthorRole.ASSISTANT | ||
| assert history[7].role == AuthorRole.TOOL | ||
|
|
||
| reducer = ChatHistoryTruncationReducer(target_count=2, threshold_count=0) | ||
| reducer.messages = history | ||
| result = await reducer.reduce() | ||
|
|
||
| assert result is not None | ||
| assert _has_result(result.messages, "call-2") | ||
| assert _has_call(result.messages, "call-2"), "A function result was retained without its matching function call." | ||
|
|
||
|
|
||
| async def test_truncation_keeps_adjacent_function_call_result_pair(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 1"), | ||
| _assistant_call("call-1"), | ||
| _tool_result("call-1"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 2"), | ||
| _assistant_call("call-2"), | ||
| _tool_result("call-2"), | ||
| ] | ||
|
|
||
| reducer = ChatHistoryTruncationReducer(target_count=2, threshold_count=0) | ||
| reducer.messages = history | ||
| result = await reducer.reduce() | ||
|
|
||
| assert result is not None | ||
| assert _has_call(result.messages, "call-2") | ||
| assert _has_result(result.messages, "call-2") | ||
|
|
||
|
|
||
| async def test_truncation_keeps_multiple_calls_in_one_assistant_message(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 1"), | ||
| _assistant_call("call-1"), | ||
| _tool_result("call-1"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 2"), | ||
| ChatMessageContent(role=AuthorRole.ASSISTANT, content=""), | ||
| ChatMessageContent(role=AuthorRole.TOOL, content=""), | ||
| ] | ||
| assert history[4].role == AuthorRole.ASSISTANT | ||
| assert history[5].role == AuthorRole.TOOL | ||
| history[4].items.extend([ | ||
| FunctionCallContent(id="call-2", function_name="search", plugin_name="plugin", arguments={"q": "2"}), | ||
| FunctionCallContent(id="call-3", function_name="lookup", plugin_name="plugin", arguments={"q": "3"}), | ||
| ]) | ||
| history[5].items.extend([ | ||
| FunctionResultContent(id="call-2", function_name="search", plugin_name="plugin", result="result-2"), | ||
| FunctionResultContent(id="call-3", function_name="lookup", plugin_name="plugin", result="result-3"), | ||
| ]) | ||
|
|
||
| reducer = ChatHistoryTruncationReducer(target_count=2, threshold_count=0) | ||
| reducer.messages = history | ||
| result = await reducer.reduce() | ||
|
|
||
| assert result is not None | ||
| assert _has_call(result.messages, "call-2") | ||
| assert _has_call(result.messages, "call-3") | ||
| assert _has_result(result.messages, "call-2") | ||
| assert _has_result(result.messages, "call-3") | ||
|
|
||
|
|
||
| async def test_truncation_does_not_reduce_when_threshold_window_does_not_trigger(): | ||
| history = [ | ||
| _assistant_call("call-a"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message"), | ||
| _tool_result("call-a"), | ||
| ] | ||
|
|
||
| reducer = ChatHistoryTruncationReducer(target_count=2, threshold_count=2) | ||
| reducer.messages = history | ||
| result = await reducer.reduce() | ||
|
|
||
| assert result is None | ||
|
|
||
|
|
||
| async def test_truncation_keeps_pair_when_threshold_window_selects_user_boundary(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 0"), | ||
| _assistant_call("call-a"), | ||
| _tool_result("call-a"), | ||
| _assistant_call("call-b"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 1"), | ||
| _tool_result("call-b"), | ||
| ] | ||
|
|
||
| reducer = ChatHistoryTruncationReducer(target_count=2, threshold_count=1) | ||
| reducer.messages = history | ||
| result = await reducer.reduce() | ||
|
|
||
| assert result is not None | ||
| assert _has_result(result.messages, "call-b") | ||
| assert _has_call(result.messages, "call-b") | ||
|
|
||
|
|
||
| async def test_truncation_closure_keeps_chained_nonadjacent_pairs(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 0"), | ||
| _assistant_call("call-a"), | ||
| ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant between calls."), | ||
| _assistant_call("call-b"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 1"), | ||
| _tool_result("call-b"), | ||
| _tool_result("call-a"), | ||
| ] | ||
|
|
||
| reducer = ChatHistoryTruncationReducer(target_count=2, threshold_count=1) | ||
| reducer.messages = history | ||
| result = await reducer.reduce() | ||
|
|
||
| assert result is not None | ||
| assert _has_call(result.messages, "call-a") | ||
| assert _has_result(result.messages, "call-a") | ||
| assert _has_call(result.messages, "call-b") | ||
| assert _has_result(result.messages, "call-b") | ||
|
|
||
|
|
||
| def test_locate_safe_reduction_index_rejects_pair_crossing_offset_boundary(): | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 0"), | ||
| _assistant_call("call-a"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="User message 1"), | ||
| _tool_result("call-a"), | ||
| ] | ||
|
|
||
| assert ( | ||
| locate_safe_reduction_index( | ||
| history=history, | ||
| target_count=1, | ||
| threshold_count=0, | ||
| offset_count=2, | ||
| has_system_message=False, | ||
| ) | ||
| is None | ||
| ) | ||
|
|
||
|
|
||
| async def test_truncation_preserves_system_message_while_pairs_are_safe(): | ||
| reducer = ChatHistoryTruncationReducer(target_count=3, system_message="System prompt") | ||
| reducer.add_message(ChatMessageContent(role=AuthorRole.USER, content="User message 1")) | ||
| reducer.add_message(ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant 1")) | ||
| reducer.add_message(ChatMessageContent(role=AuthorRole.USER, content="User message 2")) | ||
| reducer.add_message(ChatMessageContent(role=AuthorRole.ASSISTANT, content="Assistant 2")) | ||
|
|
||
| result = await reducer.reduce() | ||
|
|
||
| assert result is not None | ||
| assert result.messages[0].role == AuthorRole.SYSTEM | ||
| assert result.messages[0].content == "System prompt" |
60 changes: 60 additions & 0 deletions
60
python/tests/unit/contents/test_chat_history_truncation_reversed_results.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # Copyright (c) Microsoft. All rights reserved. | ||
|
|
||
| from semantic_kernel.contents.chat_message_content import ChatMessageContent | ||
| from semantic_kernel.contents.function_call_content import FunctionCallContent | ||
| from semantic_kernel.contents.function_result_content import FunctionResultContent | ||
| from semantic_kernel.contents.history_reducer.chat_history_truncation_reducer import ChatHistoryTruncationReducer | ||
| from semantic_kernel.contents.utils.author_role import AuthorRole | ||
|
|
||
|
|
||
| def _assistant_call(call_id: str) -> ChatMessageContent: | ||
| m = ChatMessageContent(role=AuthorRole.ASSISTANT, content="") | ||
| m.items.append(FunctionCallContent(id=call_id, function_name="f", plugin_name="p", arguments={})) | ||
| return m | ||
|
|
||
|
|
||
| def _tool_message_with(results: list[str]) -> ChatMessageContent: | ||
| m = ChatMessageContent(role=AuthorRole.TOOL, content="") | ||
| for rid in results: | ||
| m.items.append(FunctionResultContent(id=rid, function_name="f", plugin_name="p", result=f"r-{rid}")) | ||
| return m | ||
|
|
||
|
|
||
| def _has_call(history: list[ChatMessageContent], call_id: str) -> bool: | ||
| return any( | ||
| isinstance(item, FunctionCallContent) and item.id == call_id for message in history for item in message.items | ||
| ) | ||
|
|
||
|
|
||
| def _has_result(history: list[ChatMessageContent], call_id: str) -> bool: | ||
| return any( | ||
| isinstance(item, FunctionResultContent) and item.id == call_id for message in history for item in message.items | ||
| ) | ||
|
|
||
|
|
||
| async def test_truncation_does_not_orphan_earlier_call_when_results_reversed_in_one_message(): | ||
| # c2 call is at msg1 (earlier). c3 call is at msg3 (later). | ||
| # Both results are in ONE tool message (msg4), but ordered [result_c3, result_c2] | ||
| # (later call first). The break bug skipped result_c2 in pair matching, leaving | ||
| # c2's result orphaned when the cut removes msg1 but keeps msg4. | ||
| history = [ | ||
| ChatMessageContent(role=AuthorRole.USER, content="u0"), | ||
| _assistant_call("c2"), | ||
| ChatMessageContent(role=AuthorRole.USER, content="u1"), | ||
| _assistant_call("c3"), | ||
| _tool_message_with(["c3", "c2"]), # reversed: later call result first | ||
| ChatMessageContent(role=AuthorRole.ASSISTANT, content="text"), | ||
| ] | ||
|
|
||
| reducer = ChatHistoryTruncationReducer(target_count=3, threshold_count=0) | ||
| reducer.messages = history | ||
| result = await reducer.reduce() | ||
|
|
||
| assert result is not None | ||
| # If c3's result is kept, c3's call must be kept. | ||
| if _has_result(result.messages, "c3"): | ||
| assert _has_call(result.messages, "c3"), "c3 result kept without c3 call" | ||
| # Critical: if c2's result is kept (it is in the same msg as c3's result), | ||
| # c2's call must also be kept. Without the fix, c2 is orphaned. | ||
| if _has_result(result.messages, "c2"): | ||
| assert _has_call(result.messages, "c2"), "c2 result kept without c2 call (orphan)" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in c814f99. Removed the early
breakso everyFunctionResultContentitem in a message is collected;del call_ids_seen[call_id]already prevents double-matching.Two new test files:
test_chat_history_multi_result_pairing.py(internal helper, 2 cases) andtest_chat_history_truncation_reversed_results.py— a publicChatHistoryTruncationReducerregression where calls sit at different message indices (c2 at msg1, c3 at msg3) with results reversed in one tool message, orphaning the earlier call on base.Local validation on the source-equivalent blob: focused 11 passed (3 new + 8 existing); base ablation 2 fail / 1 pass; restore 11 passed; full contents 428 passed, 66 warnings. Ruff passed on the three changed files; an independent typecheck of the changed production file passed. The earlier producer package-wide check covered 556 files (not independently rerun). Warning set unchanged from base. AI assistance was used.