diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index c7a65e80d3..f04769f69a 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1198,12 +1198,72 @@ 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)) +def _format_summary_content(content: Content) -> str: + """Render one content item for the summarizer input transcript. + + 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}" + 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 = "" 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": + rendered += f" approved={content.approved}" + return rendered + return "" + + def _format_summary_message(index: int, message: Message) -> str: - content_text = message.text + 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 b0e82ae94f..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 @@ -36,6 +37,7 @@ included_token_count, ) from agent_framework._compaction import ( + _format_summary_message, _select_summary_input_groups, _serialize_message, append_compaction_message, @@ -972,6 +974,184 @@ 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" + + +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 + + +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: