Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/specs/004-python-function-calling-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
49 changes: 49 additions & 0 deletions python/packages/core/agent_framework/_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +491 to +493

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.

What happens when compaction creates a summary of an earlier summary? working_messages places the outer summary before the inner one, so this pass skips the outer summary before its anchor has been inserted. The inner summary is then inserted while still excluded, and skip_excluded=True removes it along with the original tool group, recreating the silent data loss this PR is meant to prevent. Could this resolve the summary dependencies to a fixed point, like _reconcile_compaction_summaries does?

Comment on lines +483 to +493

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.

Would it make sense to share the existing summary-reconciliation logic here? _reconcile_compaction_summaries already resolves nested dependencies and rejects summaries that include messages outside the owning list, while this new path accepts the first matching back-link in one forward pass. Generalizing that helper to take the transcript's message identities would keep ordering and caller-owned-input filtering in one implementation instead of requiring future compaction changes to update two different algorithms.

transcript_identities.add(id(message))


def _write_group_annotation(
message: Message,
*,
Expand Down
10 changes: 10 additions & 0 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

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.

Could we make sure this merged transcript is included on every terminal path? When the next model turn produces an approval request, user-input request, or middleware termination, _process_model_function_calls returns action="return" and _get_response_with_function_invocation returns response directly at _tools.py:3369. The merged summary and the earlier tool group remain only in function_call_messages, so the returned and persisted transcript still loses the compacted history on those supported exits.

if options.get("tool_choice") == "none" and _function_call_limit_reached(
total_function_calls, max_function_calls
):
Expand Down Expand Up @@ -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(
Expand Down
82 changes: 82 additions & 0 deletions python/packages/core/tests/core/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pytest import raises

from agent_framework import (
EXCLUDED_KEY,
GROUP_ANNOTATION_KEY,
GROUP_TOKEN_COUNT_KEY,
Agent,
Expand All @@ -36,6 +37,7 @@
SlidingWindowStrategy,
SupportsAgentRun,
SupportsChatGetResponse,
ToolResultCompactionStrategy,
TruncationStrategy,
chat_middleware,
enqueue_messages,
Expand Down Expand Up @@ -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


Expand Down
117 changes: 117 additions & 0 deletions python/packages/core/tests/core/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from agent_framework import (
EXCLUDED_KEY,
GROUP_ANNOTATION_KEY,
GROUP_TOKEN_COUNT_KEY,
BaseChatClient,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.

import pickle

from collections.abc import AsyncIterable, Awaitable
from typing import Any, Literal, overload

Expand Down
Loading