diff --git a/python/semantic_kernel/contents/utils/hashing.py b/python/semantic_kernel/contents/utils/hashing.py index 02b341427027..738fe06a5a08 100644 --- a/python/semantic_kernel/contents/utils/hashing.py +++ b/python/semantic_kernel/contents/utils/hashing.py @@ -41,8 +41,15 @@ def make_hashable(input: Any, visited=None) -> Any: visited[unique_obj_id] = items return items - # Convert lists, sets, and tuples to tuples - if isinstance(input, (list, set, tuple)): + # Preserve unordered equality for sets, including equality with frozensets. + if isinstance(input, (set, frozenset)): + visited[unique_obj_id] = None + set_items = frozenset(make_hashable(item, visited) for item in input) + visited[unique_obj_id] = set_items + return set_items + + # Convert ordered sequences to tuples. + if isinstance(input, (list, tuple)): visited[unique_obj_id] = None items = tuple(make_hashable(item, visited) for item in input) visited[unique_obj_id] = items diff --git a/python/tests/unit/agents/chat_completion/test_chat_history_channel.py b/python/tests/unit/agents/chat_completion/test_chat_history_channel.py index 7287456db127..02b3eac948da 100644 --- a/python/tests/unit/agents/chat_completion/test_chat_history_channel.py +++ b/python/tests/unit/agents/chat_completion/test_chat_history_channel.py @@ -250,3 +250,27 @@ async def test_receive_skips_file_references(chat_history_channel): assert len(channel.messages[2].items) == 2 assert channel.messages[2].items[0].result == "normal content 2" assert channel.messages[2].items[1].text == "Normal message" + + +async def test_invoke_deduplicates_equal_set_results(chat_history_channel): + """Equivalent set results must not be appended twice by the channel.""" + channel = chat_history_channel + channel.add_user_message("Look up the values") + stored = ChatMessageContent( + role=AuthorRole.TOOL, + items=[FunctionResultContent(id="call", name="lookup", result=set([0, 8]))], + ) + returned = ChatMessageContent( + role=AuthorRole.TOOL, + items=[FunctionResultContent(id="call", name="lookup", result=set([8, 0]))], + ) + assert stored == returned + + class AgentWithHistoryMutation: + async def invoke(self, **kwargs): + channel.messages.append(stored) + yield AgentResponseItem(message=returned, thread=channel.thread) + + received = [message async for _, message in channel.invoke(AgentWithHistoryMutation())] + assert received == [stored] + assert channel.messages == [channel.messages[0], stored] diff --git a/python/tests/unit/contents/test_hashing_utils.py b/python/tests/unit/contents/test_hashing_utils.py index 66bcf52c13d9..02a45ff3088e 100644 --- a/python/tests/unit/contents/test_hashing_utils.py +++ b/python/tests/unit/contents/test_hashing_utils.py @@ -194,3 +194,36 @@ def test_hash_function_call_content(): ) _ = hash(content) + + +def test_equal_sets_have_equal_hashes(): + """Set iteration order must not affect function-result hashes.""" + first = FunctionResultContent(id="call", name="lookup", result=set([0, 8])) + second = FunctionResultContent(id="call", name="lookup", result=set([8, 0])) + assert first == second + assert hash(first) == hash(second) + assert second in {first} + + +def test_set_and_frozenset_have_equal_hashes(): + """Python treats a set and a frozenset with equal elements as equal.""" + first = FunctionResultContent(id="call", name="lookup", result={0, 8}) + second = FunctionResultContent(id="call", name="lookup", result=frozenset({0, 8})) + assert first == second + assert hash(first) == hash(second) + + +def test_nested_equal_sets_have_equal_hashes(): + """Sets nested in function results retain unordered equality.""" + first = FunctionResultContent(id="call", name="lookup", result={"values": [set([0, 8])]}) + second = FunctionResultContent(id="call", name="lookup", result={"values": [set([8, 0])]}) + assert first == second + assert hash(first) == hash(second) + + +def test_mixed_set_elements_can_be_hashed(): + """Unordered hashing must not require ordering unlike element types.""" + first = FunctionResultContent(id="call", name="lookup", result={1, "one", (2, 3)}) + second = FunctionResultContent(id="call", name="lookup", result=frozenset({1, "one", (2, 3)})) + assert first == second + assert hash(first) == hash(second)