Repository navigation
Python: Fix extract_range dropping multi-call assistant message and reordering parallel tool results (#14485) - #14778
Conversation
…ings in extract_range Signed-off-by: Hardik Kaurani <[email protected]>
…ning assistant calls Signed-off-by: Hardik Kaurani <[email protected]>
…ract_range Signed-off-by: Hardik Kaurani <[email protected]>
…iltering (microsoft#14485) Signed-off-by: Hardik Kaurani <[email protected]>
…result messages Signed-off-by: Hardik Kaurani <[email protected]>
|
Hi Eduard van Valkenburg (@eavanvalkenburg), I've opened this PR to resolve #14485. It ensures parallel tool calls in Tests and formatting checks are passing locally. Whenever you have a moment, I'd appreciate your review and feedback. I'm happy to address any suggestions. Thank you! |
Muhammad Abdullah Butt (1AbdullahButt)
left a comment
There was a problem hiding this comment.
The bookkeeping part is right: call_to_results holds every result of a parallel call, and the kept results come back in chronological order. I followed both of the issue's examples through the new loop. But the "keep the call, drop one result" outcome still hands the connector an invalid history.
In the #14485 example, the call message (msg1) is appended unchanged at chat_history_reducer_utils.py:250. It still holds both FunctionCallContent items, call_paris and call_tokyo, but only call_paris has a result after it. OpenAI-style chat APIs require a tool message for every tool_call_id in an assistant message. That's the "An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'" 400, described in LangChain's INVALID_TOOL_RESULTS page. So the reduced history fails on the next request, the same as the orphaned TOOL message did on main, just from the other side.
Two ways to keep the output valid:
- When some of a call's in-range results are filtered out, append a copy of
msgwhoseitemskeep only theFunctionCallContents whoseidmatches a kept result (plus any non-call items). The pair is then consistent. - Or treat the call and all its results as one unit: if any result is filtered, drop the call and every result. That's simpler, and it is what
preserve_pairspromised before parallel calls came into it.
Whichever you pick, a test assertion that every FunctionCallContent.id in the output has a matching FunctionResultContent later in the output would cover the issue's case and the general invariant.
Smaller: when every in-range result is filtered but some results lie past end, the call is kept (if not kept_in_range and not out_of_range_results) with no result inside the range. That matches main's out-of-range behaviour, but the same invariant test would flag it.
I read the diff and get_call_result_pairs at the PR head, and git merge-tree against main is clean. I didn't run the tests.
Reviewed with help from Claude Code.
…without kept results in extract_range Signed-off-by: Hardik Kaurani <[email protected]>
…nge tool filtering Signed-off-by: Hardik Kaurani <[email protected]>
|
Thanks for the careful review, Muhammad Abdullah Butt (@1AbdullahButt) — good catch on the dangling I updated the PR with the following changes:
|
Motivation and Context
Fixes #14485
extract_range()inpython/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.pyis used byChatHistorySummarizationReducerandChatHistoryTruncationReducer(withpreserve_pairs=True) to ensure that function calls and their corresponding results are kept or skipped together without leaving orphaned messages.Previously,
pair_mapwas constructed as a single flat dictionary:When an assistant message contains multiple parallel tool calls (the standard shape for parallel/multi-function calling), all tool calls share the same
call_index(cidx). Becausepair_map[cidx] = ridxoverwrote previous entries on each iteration, only the last result index was kept forcidx.This caused two issues:
filter_funcwas used to drop just one parallel tool result (e.g. moderation or specific error filtering), the call message evaluated its keep/skip condition solely against the single overwriting result inpair_map[cidx]. If that result happened to be the one filtered out, the entire assistant message was dropped even though sibling tool results were kept, producing orphanedAuthorRole.TOOLmessages without a preceding assistanttool_callsmessage.pair_map[idx]inserted the last result right after the call message, while earlier results were processed later in the loop, corrupting the chronological order of parallel tool results.Description
pair_mapwithcall_to_results: dict[int, list[int]](mapping each call index to all of its corresponding result indices) andresult_to_call: dict[int, int].extract_range()so that:filter_func, the assistant call message is preserved alongside the surviving results.TOOLmessage contains multipleFunctionResultContentitems.tests/unit/contents/test_chat_history_reducer_utils.py:test_extract_range_parallel_tool_calls_filter_partial_result: verifies that dropping one parallel result preserves the assistant call message and sibling result.test_extract_range_parallel_tool_calls_preserves_chronological_order: verifies chronological ordering of parallel results.test_extract_range_parallel_tool_calls_filter_call_message_drops_all_results: verifies that filtering the call message drops all paired results.test_extract_range_parallel_tool_calls_filter_all_results_drops_call_message: verifies that filtering all results drops the call message.test_extract_range_multiple_results_in_single_message: verifies correct handling of multiple results within a single message.Contribution Checklist