From 22965a745f4c8b92f80d0d87b40cd1a4b7cf0a3d Mon Sep 17 00:00:00 2001 From: JHf0912 <2677569277@qq.com> Date: Sat, 5 Sep 2026 15:53:07 +0800 Subject: [PATCH 1/4] refactor(core): scaffold structured summary content rendering Introduce _format_summary_content as the dispatch point for per-content summary rendering and rewrite _format_summary_message to combine structured renderings with Message.text, keeping the legacy fallback to content types. Behavior is byte-identical for existing inputs; tool-call and tool-result branches follow in the next commit. --- .../packages/core/agent_framework/_compaction.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index c7a65e80d3..6b505864a6 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1202,8 +1202,21 @@ def _tool_result_text(value: Any) -> str: return str(cast(object, value)) +def _format_summary_content(content: Content) -> str: + """Render one content item for the summarizer input transcript. + + Returns an empty string when the item has no structured rendering of its + own (text contents are aggregated via ``Message.text`` instead), so callers + can fall back to the legacy rendering. + """ + return "" + + def _format_summary_message(index: int, message: Message) -> str: - content_text = message.text + parts = [_format_summary_content(content) for content in message.contents] + if message.text: + parts.append(message.text) + content_text = "; ".join(part for part in parts if part) if not content_text: content_text = ", ".join(content.type for content in message.contents) return f"{index}. [{message.role}] {content_text}" From 0466d67a1848b4bfec818143cc9bcef7a4eb9175 Mon Sep 17 00:00:00 2001 From: JHf0912 <2677569277@qq.com> Date: Sat, 5 Sep 2026 15:55:32 +0800 Subject: [PATCH 2/4] fix(core): include function call details in summarizer input Render function_call contents with name, arguments, and call id, and function_result contents with result text, exception, and call id in _format_summary_content, so the summarizer LLM sees the tool trajectory instead of a bare content type. Text-only messages keep their legacy rendering byte-for-byte; messages mixing text and tool contents now include both. --- .../core/agent_framework/_compaction.py | 20 +++++- .../core/tests/core/test_compaction.py | 63 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index 6b505864a6..f84301a261 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1205,10 +1205,24 @@ def _tool_result_text(value: Any) -> str: def _format_summary_content(content: Content) -> str: """Render one content item for the summarizer input transcript. - Returns an empty string when the item has no structured rendering of its - own (text contents are aggregated via ``Message.text`` instead), so callers - can fall back to the legacy rendering. + Tool calls and results are rendered with their name, arguments, result + text, and call id so the summarizer sees the tool trajectory instead of a + bare content type. Text contents are aggregated via ``Message.text`` + instead. Returns an empty string when the item has no structured rendering + of its own, so callers can fall back to the legacy rendering. """ + if content.type == "function_call": + arguments = _tool_result_text(content.arguments) if content.arguments is not None else "" + call = f"function_call {content.name or ''}({arguments})" + if content.call_id: + call += f" [call_id={content.call_id}]" + return call + if content.type == "function_result": + result_text = _tool_result_text(content.result) if content.result is not None else "no result" + if content.exception: + result_text = f"error({content.exception}): {result_text}" + call_id_suffix = f" [call_id={content.call_id}]" if content.call_id else "" + return f"function_result: {result_text}{call_id_suffix}" return "" diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index b0e82ae94f..55b85219cb 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -36,6 +36,7 @@ included_token_count, ) from agent_framework._compaction import ( + _format_summary_message, _select_summary_input_groups, _serialize_message, append_compaction_message, @@ -972,6 +973,68 @@ def test_summary_input_selection_does_not_retokenize_selected_transcript() -> No ) +def test_format_summary_message_includes_function_call_details() -> None: + message = Message( + role="assistant", + contents=[Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city":"Seattle"}')], + ) + + rendered = _format_summary_message(1, message) + + assert "get_weather" in rendered + assert '{"city":"Seattle"}' in rendered + assert "[call_id=call_1]" in rendered + + +def test_format_summary_message_includes_function_result_and_exception() -> None: + message = Message( + role="tool", + contents=[Content.from_function_result(call_id="call_1", result="42", exception="ValueError")], + ) + + rendered = _format_summary_message(2, message) + + assert "function_result" in rendered + assert "42" in rendered + assert "error(ValueError)" in rendered + assert "[call_id=call_1]" in rendered + + +def test_format_summary_message_renders_function_result_without_call_id() -> None: + message = Message( + role="tool", + contents=[Content("function_result", call_id=None, result="done")], + ) + + rendered = _format_summary_message(3, message) + + assert "done" in rendered + assert "call_id" not in rendered + + +def test_format_summary_message_combines_tool_calls_with_text() -> None: + message = Message( + role="assistant", + contents=[ + "I'll check the weather.", + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city":"Seattle"}'), + ], + ) + + rendered = _format_summary_message(4, message) + + assert "I'll check the weather." in rendered + assert "get_weather" in rendered + + +def test_format_summary_message_preserves_text_only_messages() -> None: + message = Message(role="user", contents=["hello world"]) + + rendered = _format_summary_message(5, message) + + assert rendered == "5. [user] hello world" + + async def test_summarization_strategy_returns_false_when_summary_generation_fails( caplog: Any, ) -> None: From 7cced634b4c43cbaff4f2ea75b6fb219b46dd2b4 Mon Sep 17 00:00:00 2001 From: JHf0912 <2677569277@qq.com> Date: Sat, 5 Sep 2026 15:57:10 +0800 Subject: [PATCH 3/4] fix(core): include MCP tool and approval details in summarizer input Extend _format_summary_content with mcp_server_tool_call / mcp_server_tool_result (tool name, arguments, output, call id) and function_approval_request / function_approval_response (nested call name, approval id, decision) branches, completing the tool trajectory visible to the summarizer LLM. Fixes #8086 --- .../core/agent_framework/_compaction.py | 20 ++++++ .../core/tests/core/test_compaction.py | 65 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index f84301a261..a53ef65fdf 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1223,6 +1223,26 @@ def _format_summary_content(content: Content) -> str: result_text = f"error({content.exception}): {result_text}" call_id_suffix = f" [call_id={content.call_id}]" if content.call_id else "" return f"function_result: {result_text}{call_id_suffix}" + if content.type == "mcp_server_tool_call": + arguments = _tool_result_text(content.arguments) if content.arguments is not None else "" + call = f"mcp_tool_call {content.tool_name or ''}({arguments})" + if content.call_id: + call += f" [call_id={content.call_id}]" + return call + if content.type == "mcp_server_tool_result": + result_text = _tool_result_text(content.output) + if content.exception: + result_text = f"error({content.exception}): {result_text}" + call_id_suffix = f" [call_id={content.call_id}]" if content.call_id else "" + return f"mcp_tool_result: {result_text}{call_id_suffix}" + if content.type in ("function_approval_request", "function_approval_response"): + nested_call = content.function_call + name = nested_call.name if nested_call is not None else "" + label = "approval_request" if content.type == "function_approval_request" else "approval_response" + rendered = f"{label}: {name} [id={content.id}]" + if content.type == "function_approval_response": + rendered += f" approved={content.approved}" + return rendered return "" diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index 55b85219cb..2d0075553e 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -1035,6 +1035,71 @@ def test_format_summary_message_preserves_text_only_messages() -> None: assert rendered == "5. [user] hello world" +def test_format_summary_message_includes_mcp_tool_details() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp_1", + tool_name="search", + server_name="test_server", + arguments='{"query":"x"}', + ), + Content.from_mcp_server_tool_result( + call_id="mcp_1", + output=[Content.from_text("found")], + ), + ], + ) + + rendered = _format_summary_message(6, message) + + assert "search" in rendered + assert '{"query":"x"}' in rendered + assert "[call_id=mcp_1]" in rendered + assert "found" in rendered + + +def test_format_summary_message_includes_approval_request() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_function_approval_request( + id="approval_1", + function_call=Content.from_function_call( + call_id="call_1", name="send_email", arguments='{"to":"a@b.c"}' + ), + ) + ], + ) + + rendered = _format_summary_message(7, message) + + assert "approval_request" in rendered + assert "send_email" in rendered + assert "[id=approval_1]" in rendered + + +def test_format_summary_message_includes_approval_response() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_function_approval_response( + approved=True, + id="approval_1", + function_call=Content.from_function_call( + call_id="call_1", name="send_email", arguments='{"to":"a@b.c"}' + ), + ) + ], + ) + + rendered = _format_summary_message(8, message) + + assert "approval_response" in rendered + assert "approved=True" in rendered + + async def test_summarization_strategy_returns_false_when_summary_generation_fails( caplog: Any, ) -> None: From 6b5360da2e1389e800be93dbd4046b603de0c1da Mon Sep 17 00:00:00 2001 From: JHf0912 <2677569277@qq.com> Date: Sat, 5 Sep 2026 17:21:41 +0800 Subject: [PATCH 4/4] fix(core): harden summary content rendering edge cases - Stringify non-JSON-serializable MCP result mappings with the same json.dumps(default=str) fallback used by Content.from_function_result, preventing compaction crashes inside _select_summary_input_groups. - Preserve time order of text and structured contents in _format_summary_message by emitting consecutive text blocks in place. - Use tool_name as the fallback identity for MCP tool calls nested in approval contents. Addresses review feedback on PR #8087; adds three regression tests. --- .../core/agent_framework/_compaction.py | 25 ++++++--- .../core/tests/core/test_compaction.py | 52 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index a53ef65fdf..f04769f69a 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1198,7 +1198,10 @@ def _tool_result_text(value: Any) -> str: if text_parts: return "\n".join(text_parts) if isinstance(value, Mapping): - return json.dumps(cast(Mapping[str, object], value), ensure_ascii=False) + try: + return json.dumps(cast(Mapping[str, object], value), ensure_ascii=False, default=str) + except (TypeError, ValueError): + return str(cast(object, value)) return str(cast(object, value)) @@ -1237,7 +1240,7 @@ def _format_summary_content(content: Content) -> str: return f"mcp_tool_result: {result_text}{call_id_suffix}" if content.type in ("function_approval_request", "function_approval_response"): nested_call = content.function_call - name = nested_call.name if nested_call is not None else "" + name = "" if nested_call is None else nested_call.name or nested_call.tool_name or "" label = "approval_request" if content.type == "function_approval_request" else "approval_response" rendered = f"{label}: {name} [id={content.id}]" if content.type == "function_approval_response": @@ -1247,10 +1250,20 @@ def _format_summary_content(content: Content) -> str: def _format_summary_message(index: int, message: Message) -> str: - parts = [_format_summary_content(content) for content in message.contents] - if message.text: - parts.append(message.text) - content_text = "; ".join(part for part in parts if part) + parts: list[str] = [] + pending_text: list[str] = [] + for content in message.contents: + rendered = _format_summary_content(content) + if rendered: + if pending_text: + parts.append(" ".join(pending_text)) + pending_text = [] + parts.append(rendered) + elif content.type == "text" and content.text: + pending_text.append(content.text) + if pending_text: + parts.append(" ".join(pending_text)) + content_text = "; ".join(parts) if not content_text: content_text = ", ".join(content.type for content in message.contents) return f"{index}. [{message.role}] {content_text}" diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index 2d0075553e..cb2aa60e36 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import date from typing import Any import pytest @@ -1100,6 +1101,57 @@ def test_format_summary_message_includes_approval_response() -> None: assert "approved=True" in rendered +def test_format_summary_message_stringifies_non_json_mcp_result_without_crash() -> None: + message = Message( + role="tool", + contents=[Content("mcp_server_tool_result", call_id="mcp_1", output={"when": date(2026, 1, 1)})], + ) + + rendered = _format_summary_message(9, message) + + assert "2026" in rendered + assert "[call_id=mcp_1]" in rendered + + +def test_format_summary_message_preserves_time_order_for_mixed_contents() -> None: + message = Message( + role="assistant", + contents=[ + "I'll check the weather.", + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city":"Seattle"}'), + "Please wait.", + ], + ) + + rendered = _format_summary_message(10, message) + + assert rendered.index("I'll check the weather.") < rendered.index("function_call") + assert rendered.index("function_call") < rendered.index("Please wait.") + + +def test_format_summary_message_uses_tool_name_for_mcp_approval() -> None: + message = Message( + role="assistant", + contents=[ + Content.from_function_approval_request( + id="approval_mcp_1", + function_call=Content.from_mcp_server_tool_call( + call_id="mcp_1", + tool_name="search", + server_name="test_server", + arguments='{"query":"x"}', + ), + ) + ], + ) + + rendered = _format_summary_message(11, message) + + assert "approval_request" in rendered + assert "search" in rendered + assert "[id=approval_mcp_1]" in rendered + + async def test_summarization_strategy_returns_false_when_summary_generation_fails( caplog: Any, ) -> None: