Describe the bug
extract_range() in python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py is used by ChatHistorySummarizationReducer / ChatHistoryTruncationReducer (via preserve_pairs=True) to make sure a function call is never separated from its result when the history is reduced.
The pair lookup is built like this (current main):
pair_map = {}
if preserve_pairs:
pairs = get_call_result_pairs(history)
for cidx, ridx in pairs:
pair_map[cidx] = ridx
pair_map[ridx] = cidx
get_call_result_pairs() returns one (call_index, result_index) tuple per function call, so when a single assistant message contains more than one FunctionCallContent (the normal shape for parallel/multi tool calling), several pairs share the same call_index. Because pair_map is a plain dict, pair_map[cidx] = ridx silently overwrites earlier entries, so only the last result for that call index survives in the forward direction. The reverse entries (pair_map[ridx] = cidx) don't collide, so each result still points back at the call correctly - only the call's own forward pointer is wrong.
Later in extract_range, when the call message's own turn comes up, its keep/skip decision (and the filter_func "skip both" check) is made using that single, possibly-wrong paired_idx, not the full set of results tied to that call. If a filter_func is used to drop just one of several parallel tool results, the call message ends up being evaluated only against the result that happens to still be in pair_map[cidx], which can cause the call to be dropped even though another of its own results is being kept - leaving a TOOL result message in the output with no corresponding assistant tool_calls message at all.
To Reproduce
Ran this against the actual installed semantic-kernel package (pip show semantic-kernel -> 1.44.1; diffed byte-for-byte identical to the current main copy of chat_history_reducer_utils.py before running):
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.utils.author_role import AuthorRole
from semantic_kernel.contents.history_reducer.chat_history_reducer_utils import extract_range, get_call_result_pairs
msg0 = ChatMessageContent(role=AuthorRole.USER, content="weather?")
msg1 = ChatMessageContent(
role=AuthorRole.ASSISTANT,
items=[
FunctionCallContent(id="call_paris", name="get_weather", arguments='{"city":"Paris"}'),
FunctionCallContent(id="call_tokyo", name="get_weather", arguments='{"city":"Tokyo"}'),
],
)
msg2 = ChatMessageContent(role=AuthorRole.TOOL, items=[FunctionResultContent(id="call_paris", name="get_weather", result="15C")])
msg3 = ChatMessageContent(role=AuthorRole.TOOL, items=[FunctionResultContent(id="call_tokyo", name="get_weather", result="22C")])
msg4 = ChatMessageContent(role=AuthorRole.ASSISTANT, content="done")
history = [msg0, msg1, msg2, msg3, msg4]
print(get_call_result_pairs(history))
# [(1, 2), (1, 3)] <- both pairs share call_index 1
filter_func = lambda m: m is msg3 # drop only the Tokyo result, e.g. a moderation/error filter
out = extract_range(history, start=0, end=len(history), filter_func=filter_func, preserve_pairs=True)
for m in out:
print(m.role, [getattr(it, "id", None) for it in m.items] if m.items else m.content)
Output:
AuthorRole.USER weather?
AuthorRole.TOOL ['call_paris']
AuthorRole.ASSISTANT done
The assistant message with the two tool_calls (msg1) is gone entirely, even though call_paris's own result (msg2) is kept right after it with no call message in front of it.
Expected behavior
Filtering out only call_tokyo's result should not affect call_paris's pair. Expected output keeps the call message and the Paris result together, and drops only the Tokyo result:
AuthorRole.USER weather?
AuthorRole.ASSISTANT [call_paris, call_tokyo] (or with call_tokyo's FunctionCallContent stripped)
AuthorRole.TOOL ['call_paris']
AuthorRole.ASSISTANT done
At minimum, the call message and call_paris's result should never both disappear/appear inconsistently relative to each other - right now the call is dropped solely because of an unrelated sibling call's filtered result.
Platform
- Language: Python
- Source: pip package
semantic-kernel==1.44.1, and confirmed the relevant file is unchanged on the current main branch
- File:
python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py, function extract_range() (the pair_map construction) together with get_call_result_pairs()
Additional context
This is a distinct root cause from the interleaved-messages reordering bug already reported and fixed in the still-open #14165 (Fix extract_range reordering messages when preserving function call/result pairs). I checked that PR's rewritten implementation and it keeps the exact same pair_map[cidx] = ridx / pair_map[ridx] = cidx construction, so this dict-collision bug reproduces identically against that PR's version of the function too - it is not fixed as a side effect of #14165 and needs its own fix in how pair_map associates a call index with all of its result indices (e.g. pair_map[cidx] holding a list of result indices instead of a single int).
Related but different: #12708 and #13062 (both closed by the stale bot, not fixed) describe tool-call/result pairs being orphaned when a pair straddles the [start, end) boundary. This report is about pairs being mishandled even when everything is fully inside the requested range, purely because more than one result shares a call index.
Describe the bug
extract_range()inpython/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.pyis used byChatHistorySummarizationReducer/ChatHistoryTruncationReducer(viapreserve_pairs=True) to make sure a function call is never separated from its result when the history is reduced.The pair lookup is built like this (current
main):get_call_result_pairs()returns one(call_index, result_index)tuple per function call, so when a single assistant message contains more than oneFunctionCallContent(the normal shape for parallel/multi tool calling), several pairs share the samecall_index. Becausepair_mapis a plaindict,pair_map[cidx] = ridxsilently overwrites earlier entries, so only the last result for that call index survives in the forward direction. The reverse entries (pair_map[ridx] = cidx) don't collide, so each result still points back at the call correctly - only the call's own forward pointer is wrong.Later in
extract_range, when the call message's own turn comes up, its keep/skip decision (and thefilter_func"skip both" check) is made using that single, possibly-wrongpaired_idx, not the full set of results tied to that call. If afilter_funcis used to drop just one of several parallel tool results, the call message ends up being evaluated only against the result that happens to still be inpair_map[cidx], which can cause the call to be dropped even though another of its own results is being kept - leaving aTOOLresult message in the output with no corresponding assistanttool_callsmessage at all.To Reproduce
Ran this against the actual installed
semantic-kernelpackage (pip show semantic-kernel->1.44.1; diffed byte-for-byte identical to the currentmaincopy ofchat_history_reducer_utils.pybefore running):Output:
The assistant message with the two
tool_calls(msg1) is gone entirely, even thoughcall_paris's own result (msg2) is kept right after it with no call message in front of it.Expected behavior
Filtering out only
call_tokyo's result should not affectcall_paris's pair. Expected output keeps the call message and the Paris result together, and drops only the Tokyo result:At minimum, the call message and
call_paris's result should never both disappear/appear inconsistently relative to each other - right now the call is dropped solely because of an unrelated sibling call's filtered result.Platform
semantic-kernel==1.44.1, and confirmed the relevant file is unchanged on the currentmainbranchpython/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py, functionextract_range()(thepair_mapconstruction) together withget_call_result_pairs()Additional context
This is a distinct root cause from the interleaved-messages reordering bug already reported and fixed in the still-open #14165 (
Fix extract_range reordering messages when preserving function call/result pairs). I checked that PR's rewritten implementation and it keeps the exact samepair_map[cidx] = ridx/pair_map[ridx] = cidxconstruction, so this dict-collision bug reproduces identically against that PR's version of the function too - it is not fixed as a side effect of #14165 and needs its own fix in howpair_mapassociates a call index with all of its result indices (e.g.pair_map[cidx]holding a list of result indices instead of a single int).Related but different: #12708 and #13062 (both closed by the stale bot, not fixed) describe tool-call/result pairs being orphaned when a pair straddles the
[start, end)boundary. This report is about pairs being mishandled even when everything is fully inside the requested range, purely because more than one result shares a call index.