From e94bbfd0418dc547696b2845ae94c8cd1fa659b4 Mon Sep 17 00:00:00 2001 From: Vedant Sonkar Date: Mon, 7 Sep 2026 15:27:59 +0530 Subject: [PATCH 1/3] chore(python): apply locked ruff formatting to test_agent_executor.py Removes a blank line between stdlib import groups that the locked ruff version (and therefore the pre-commit format hook) now requires. --- python/packages/core/tests/workflow/test_agent_executor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 2cc2ed2ce6e..4b067ab7c1c 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import pickle - from collections.abc import AsyncIterable, Awaitable from typing import Any, Literal, overload From d58f77f750c6fd0d1d3c0e61be118a152ef23d8f Mon Sep 17 00:00:00 2001 From: Vedant Sonkar Date: Mon, 7 Sep 2026 15:28:06 +0530 Subject: [PATCH 2/3] fix(python): return call-level compaction summaries in agent responses In-run compaction excluded tool groups via flags on Message objects shared with the function-invocation transcript, but inserted summary messages only into the model-input list. The final ChatResponse (and the history persisted from it) therefore kept only the exclusion flags, so loading history with skip_excluded silently dropped the summarized tool results (#8099). Merge summaries back into the transcript after each model call, matching each summary to its excluded group via the SUMMARIZED_BY_SUMMARY_ID_KEY back-link. Summaries of caller-owned input messages stay out of the returned transcript. Adds regression tests for the Phase-2 terminal path, the max-iterations terminal path, and agent-level persistence with InMemoryHistoryProvider. --- .../core/agent_framework/_compaction.py | 49 ++++++++ .../packages/core/agent_framework/_tools.py | 10 ++ .../packages/core/tests/core/test_agents.py | 82 ++++++++++++ .../packages/core/tests/core/test_clients.py | 117 ++++++++++++++++++ 4 files changed, 258 insertions(+) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index c7a65e80d33..c2507f0b35b 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -445,6 +445,55 @@ def source_dependencies(summary_id: str) -> set[str]: source_messages.insert(insertion_index, message) +def _summarized_by_summary_id(message: Message) -> str | None: + annotation = _read_group_annotation_raw(message) + if annotation is None: + return None + summary_id = annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY) + return summary_id if isinstance(summary_id, str) else None + + +def _merge_compaction_summaries_into_transcript( # pyright: ignore[reportUnusedFunction] + transcript_messages: list[Message], + working_messages: Sequence[Message], +) -> None: + """Merge compaction summaries of transcript messages back into the transcript. + + Compaction annotates shared Message objects with exclusion flags but inserts summary + messages only into the working (model-input) list. A caller assembling its returned + transcript from a separate list keeps only the exclusion flags, so the summaries must + be copied back or the response (and persisted history loaded with ``skip_excluded``) + silently loses the summarized content (issue #8099). Each summary is inserted before + the first transcript message whose back-link matches the summary id, which also works + when summarized messages carry no ``message_id``. Nested summaries resolve naturally: + an inner summary is merged first (it precedes the outer one in the working list), and + the outer summary then matches the inner one through its own back-link. Summaries of + caller-owned input messages stay out — those sources are not part of the returned + transcript, so persisting such summaries would duplicate content. + """ + transcript_identities = {id(message) for message in transcript_messages} + for message in working_messages: + if id(message) in transcript_identities: + continue + annotation = _read_group_annotation_raw(message) + if annotation is None or not message.message_id: + continue + if SUMMARY_OF_MESSAGE_IDS_KEY not in annotation and SUMMARY_OF_GROUP_IDS_KEY not in annotation: + continue + insertion_index = next( + ( + index + for index, transcript_message in enumerate(transcript_messages) + if _summarized_by_summary_id(transcript_message) == message.message_id + ), + None, + ) + if insertion_index is None: + continue + transcript_messages.insert(insertion_index, message) + transcript_identities.add(id(message)) + + def _write_group_annotation( message: Message, *, diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 9b407547608..0651187d2d0 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3250,6 +3250,7 @@ async def _get_response_with_function_invocation( max_errors: int, ) -> ChatResponse[Any]: """Run the non-streaming function invocation loop.""" + from ._compaction import _merge_compaction_summaries_into_transcript # pyright: ignore[reportPrivateUsage] from ._middleware import MiddlewareFailure from ._types import ChatResponse, add_usage_details @@ -3314,6 +3315,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non client_kwargs=request_kwargs, ), ) + # Compaction inserts summaries only into prepared_messages while its exclusion + # flags land on Message objects shared with the transcript; copy the summaries + # back before the transcript is prepended to a terminal response (issue #8099). + # The merge is unconditional: compaction may also come from the inner client's + # own default strategy, which this layer does not see as a parameter. + _merge_compaction_summaries_into_transcript(function_call_messages, prepared_messages) if options.get("tool_choice") == "none" and _function_call_limit_reached( total_function_calls, max_function_calls ): @@ -3386,6 +3393,9 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non client_kwargs=request_kwargs, ), ) + # See the Phase 2 merge: the final no-tools call can compact further groups, so its + # summaries must also reach the returned transcript. + _merge_compaction_summaries_into_transcript(function_call_messages, prepared_messages) _ensure_function_invocation_limit_fallback_response(response) aggregated_usage = add_usage_details(aggregated_usage, response.usage_details) self._update_function_invocation_continuation_state( diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 4ef0d031d72..7b692250c32 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -13,6 +13,7 @@ from pytest import raises from agent_framework import ( + EXCLUDED_KEY, GROUP_ANNOTATION_KEY, GROUP_TOKEN_COUNT_KEY, Agent, @@ -36,6 +37,7 @@ SlidingWindowStrategy, SupportsAgentRun, SupportsChatGetResponse, + ToolResultCompactionStrategy, TruncationStrategy, chat_middleware, enqueue_messages, @@ -2344,6 +2346,86 @@ async def capturing_inner( assert captured_token_counts == [[23]] +async def test_agent_run_returns_and_persists_compaction_summaries( + chat_client_base: Any, +) -> None: + # End-to-end regression test for #8099: with call-level compaction and a history + # provider loading with skip_excluded=True, the run must return — and persist — the + # summaries replacing excluded tool groups; otherwise the next turn silently loses + # the summarized tool results with nothing replacing them. + from agent_framework._sessions import InMemoryHistoryProvider + + chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_1", + name="lookup_weather", + arguments='{"location": "London"}', + ) + ], + ), + response_id="resp_call_1", + ), + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_2", + name="lookup_weather", + arguments='{"location": "Paris"}', + ) + ], + ), + response_id="resp_call_2", + ), + ChatResponse(messages=Message(role="assistant", contents=["done"]), response_id="resp_done"), + ] + + provider = InMemoryHistoryProvider(skip_excluded=True) + agent = Agent( + client=chat_client_base, + tools=[lookup_weather], + compaction_strategy=ToolResultCompactionStrategy(keep_last_tool_call_groups=1), + context_providers=[provider], + ) + session = agent.create_session() + + result = await agent.run("What is the weather in London?", session=session) + + def _is_tool_result_summary(message: Message) -> bool: + return message.role == "assistant" and (message.text or "").startswith("[Tool results:") + + returned_summaries = [message for message in result.messages if _is_tool_result_summary(message)] + assert len(returned_summaries) == 1, [message.text for message in result.messages] + assert "London" in (returned_summaries[0].text or "") + + stored_messages = cast(list[Message], session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]["messages"]) + stored_summaries = [message for message in stored_messages if _is_tool_result_summary(message)] + assert len(stored_summaries) == 1, [message.text for message in stored_messages] + + # A follow-up turn loading with skip_excluded=True drops the excluded groups while + # the summary that replaces them survives. + loaded_messages = await provider.get_messages( + session_id="turn-2", state=cast("dict[str, Any]", session.state[InMemoryHistoryProvider.DEFAULT_SOURCE_ID]) + ) + loaded_summaries = [message for message in loaded_messages if _is_tool_result_summary(message)] + assert len(loaded_summaries) == 1 + assert "London" in (loaded_summaries[0].text or "") + assert not any(message.additional_properties.get(EXCLUDED_KEY, False) for message in loaded_messages) + assert "done" in [message.text for message in loaded_messages] + + # region Test _merge_options diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index b5c4fc8853e..984daa07077 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -7,6 +7,7 @@ import pytest from agent_framework import ( + EXCLUDED_KEY, GROUP_ANNOTATION_KEY, GROUP_TOKEN_COUNT_KEY, BaseChatClient, @@ -449,6 +450,122 @@ def _capture( assert "Paris" in summary_text +async def test_function_loop_returns_compaction_summaries_in_final_response( + chat_client_base: SupportsChatGetResponse, +) -> None: + # Regression test for #8099: compaction excludes tool groups via flags on Message + # objects shared with the returned transcript, but inserts summary messages only into + # the model-input list. The final response kept only the exclusion flags, so persisted + # history (loaded with skip_excluded=True) silently lost the summarized tool results. + chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _tool_call_response("call_1", "London"), + _tool_call_response("call_2", "Paris"), + ChatResponse(messages=Message(role="assistant", contents=["done"]), response_id="resp_done"), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["What is the weather in London?"])], + options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key] + ) + + def _is_call(message: Message, call_id: str) -> bool: + return any(content.type == "function_call" and content.call_id == call_id for content in message.contents) + + def _is_result(message: Message, call_id: str) -> bool: + return any(content.type == "function_result" and content.call_id == call_id for content in message.contents) + + summary_index = next( + (index for index, message in enumerate(response.messages) if _is_tool_result_summary(message)), None + ) + first_call_1_index = next( + (index for index, message in enumerate(response.messages) if _is_call(message, "call_1")), None + ) + call_messages = { + call_id: [ + message for message in response.messages if _is_call(message, call_id) or _is_result(message, call_id) + ] + for call_id in ("call_1", "call_2") + } + + assert summary_index is not None, [message.text for message in response.messages] + assert "London" in (response.messages[summary_index].text or "") + # The summary replaces the excluded group in transcript order: it precedes the group it summarizes. + assert first_call_1_index is not None + assert summary_index < first_call_1_index + assert all(message.additional_properties.get(EXCLUDED_KEY, False) for message in call_messages["call_1"]), [ + message.additional_properties for message in call_messages["call_1"] + ] + assert call_messages["call_2"] + assert not any(message.additional_properties.get(EXCLUDED_KEY, False) for message in call_messages["call_2"]) + + +async def test_function_loop_returns_compaction_summaries_when_iteration_budget_exhausted( + chat_client_base: SupportsChatGetResponse, +) -> None: + # Companion of test_function_loop_returns_compaction_summaries_in_final_response for the + # max-iterations terminal path: summaries inserted before the final no-tools model call + # must reach the returned transcript too, not only the model input. + chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + @tool(name="lookup_weather", approval_mode="never_require") + def lookup_weather(location: str) -> str: + return f"Weather in {location}: sunny" + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + _tool_call_response("call_1", "London"), + _tool_call_response("call_2", "Paris"), + _tool_call_response("call_3", "Tokyo"), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["What is the weather in London?"])], + options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key] + ) + + summaries = [message for message in response.messages if _is_tool_result_summary(message)] + summary_text = " ".join(message.text or "" for message in summaries) + + def _first_call_index(call_id: str) -> int | None: + return next( + ( + index + for index, message in enumerate(response.messages) + if any(content.type == "function_call" and content.call_id == call_id for content in message.contents) + ), + None, + ) + + assert len(summaries) == 2, [message.text for message in response.messages] + assert "London" in summary_text + assert "Paris" in summary_text + # Each summary precedes the excluded group it replaces in transcript order. + first_call_1_index = _first_call_index("call_1") + first_call_2_index = _first_call_index("call_2") + assert first_call_1_index is not None and first_call_2_index is not None + london_summary_index = next( + index + for index, message in enumerate(response.messages) + if _is_tool_result_summary(message) and "London" in (message.text or "") + ) + paris_summary_index = next( + index + for index, message in enumerate(response.messages) + if _is_tool_result_summary(message) and "Paris" in (message.text or "") + ) + assert london_summary_index < first_call_1_index + assert paris_summary_index < first_call_2_index + + @pytest.mark.parametrize("with_chat_middleware", [False, True]) async def test_function_loop_compaction_conversation_id_mode_does_not_resend_history( chat_client_base: SupportsChatGetResponse, From 899bf0ea9525ecce53278a186a79f0fb584497a0 Mon Sep 17 00:00:00 2001 From: Vedant Sonkar Date: Mon, 7 Sep 2026 15:28:16 +0530 Subject: [PATCH 3/3] docs(python): track in-run compaction summaries in function-calling-loop spec Adds the normative invariant that non-streaming runs return inserted compaction summaries in the final response transcript, a traceability matrix row pointing at the new regression tests, and a related-issue entry for #8099. --- docs/specs/004-python-function-calling-loop.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index d7d46058180..6531d035591 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -467,6 +467,9 @@ that manually replay messages own the equivalent rule: do not resend an approval same turn. - A trusted terminal result consumes the corresponding approval authority in explicit stateless replay; a result in a server-registered pending occurrence cannot consume that authority before local execution. +- Non-streaming runs that exclude tool groups through in-run compaction return the inserted summary messages in the + final response transcript, each positioned before the group it replaces, so history loaded with `skip_excluded` + keeps the summarized content; summaries of caller-owned input messages stay out of the returned transcript. ## Scenario-to-test matrix @@ -486,6 +489,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Declaration-only call | The call is surfaced as user input and is not executed; streaming arguments appear once while finalized request metadata remains available. | `test_declaration_only_tool`, `test_streaming_declaration_only_tool_preserves_metadata_without_duplicate_arguments` | | Function invocation disabled | The client bypasses the invocation loop without losing invocation kwargs. | `test_function_invocation_config_enabled_false`, `test_function_invocation_config_enabled_false_preserves_invocation_kwargs`, `test_streaming_function_invocation_config_enabled_false` | | Runtime tool changes | Added tools become available on the next iteration and retain approval behavior. | `test_add_tools_available_next_iteration`, `test_add_tools_with_approval_required_tool` | +| In-run compaction summaries | Summaries inserted for tool groups excluded by in-run compaction are returned in the final non-streaming response transcript before the group they replace, so history loaded with `skip_excluded` keeps the summarized content; summaries of caller-owned input messages are not added. | `packages/core/tests/core/test_clients.py::test_function_loop_returns_compaction_summaries_in_final_response`, `packages/core/tests/core/test_clients.py::test_function_loop_returns_compaction_summaries_when_iteration_budget_exhausted`, `packages/core/tests/core/test_agents.py::test_agent_run_returns_and_persists_compaction_summaries` | ### Approval pause and resume @@ -677,3 +681,4 @@ Before accepting an update, reviewers must confirm: - #6963 / #7095 — opaque reasoning-signature replay - #6074 / #7233 — reasoning-paired tool-call replay - #6450 / #6794 — provider message and tool-result serialization +- #8099 — in-run compaction summaries in the returned transcript