Skip to content

Python: Fix extract_range dropping multi-call assistant message and reordering parallel tool results (#14485) - #14778

Open
Hardik K (hardikkaurani) wants to merge 7 commits into
microsoft:mainfrom
hardikkaurani:fix/extract-range-parallel-tool-call-pairs
Open

Hardik K (hardikkaurani) wants to merge 7 commits into
microsoft:mainfrom
hardikkaurani:fix/extract-range-parallel-tool-call-pairs

Conversation

@hardikkaurani

Copy link
Copy Markdown
Contributor

Motivation and Context

Fixes #14485

extract_range() in python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.py is used by ChatHistorySummarizationReducer and ChatHistoryTruncationReducer (with preserve_pairs=True) to ensure that function calls and their corresponding results are kept or skipped together without leaving orphaned messages.

Previously, pair_map was constructed as a single flat dictionary:

pair_map = {}
for cidx, ridx in pairs:
    pair_map[cidx] = ridx
    pair_map[ridx] = cidx

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). Because pair_map[cidx] = ridx overwrote previous entries on each iteration, only the last result index was kept for cidx.

This caused two issues:

  1. Orphaned tool results when filtering: If a filter_func was 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 in pair_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 orphaned AuthorRole.TOOL messages without a preceding assistant tool_calls message.
  2. Scrambled chronological order: When iterating through the slice, eager appending of 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

  • Replaced flat pair_map with call_to_results: dict[int, list[int]] (mapping each call index to all of its corresponding result indices) and result_to_call: dict[int, int].
  • Updated extract_range() so that:
    • When evaluating an assistant call message, if only a subset of in-range parallel tool results is excluded by filter_func, the assistant call message is preserved alongside the surviving results.
    • If the call message itself is filtered out, or if all of its paired results are filtered out, both the call and its results are skipped.
    • Kept in-range results are sorted and deduplicated, guaranteeing that parallel tool results are appended in their original chronological order and preventing duplicate appends when a single TOOL message contains multiple FunctionResultContent items.
  • Added comprehensive unit tests in 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

@hardikkaurani
Hardik K (hardikkaurani) requested a review from a team as a code owner November 10, 2026 23:28
Copilot AI balanced review requested due to automatic review settings November 10, 2026 23:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@hardikkaurani

Hardik K (hardikkaurani) commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi Eduard van Valkenburg (@eavanvalkenburg),

I've opened this PR to resolve #14485. It ensures parallel tool calls in extract_range are mapped to all their associated result indices instead of being overwritten by the last index. This prevents orphaned tool results during filtering while preserving their original chronological order.

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!

@semantic-kernel-automation semantic-kernel-automation Bot added the python Pull requests for the Python Semantic Kernel label Nov 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 msg whose items keep only the FunctionCallContents whose id matches 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_pairs promised 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.

@hardikkaurani

Hardik K (hardikkaurani) commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, Muhammad Abdullah Butt (@1AbdullahButt) — good catch on the dangling FunctionCallContent IDs and the out-of-range fallback.

I updated the PR with the following changes:

  • Strip unkept tool calls on a copy: In extract_range (via _filter_call_message_items), when some of a call message's results are filtered out or outside [start, end), we now append a model_copy retaining non-call items (such as text) and only the FunctionCallContent items whose id matches a kept FunctionResultContent, without mutating the original message in history.
  • Drop calls with no kept in-range results: Removed the out_of_range_results exception so a call message with zero kept results in range is dropped rather than leaving unresponded tool_calls.
  • Tests for the general invariant: Added _assert_every_call_has_later_result to verify every FunctionCallContent.id in the extracted output has a matching FunctionResultContent later in the output, along with test cases covering partial filtering with preserved text/metadata, multi-result TOOL messages, and results past end.

This branch was successfully deployed

1 active deployment
github-app-auth — 2920cea0 Deployed Nov 11, 2026 by hardikkaurani via add_label #29673
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests for the Python Semantic Kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: extract_range() drops multi-call assistant message when filtering out just one of its parallel tool results

3 participants