From 3807df7a063518d473f077e9879cf2d735dca9a7 Mon Sep 17 00:00:00 2001 From: luyifan Date: Tue, 30 Jun 2026 06:04:14 +0800 Subject: [PATCH] fix(api-server): preserve compaction summaries on auto truncation Fixes #55224 --- gateway/platforms/api_server.py | 62 ++++++++++++++++++++++++++++++-- tests/gateway/test_api_server.py | 42 ++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 4b3b4d91228c..ddbcdf1bc6b7 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -127,6 +127,8 @@ MAX_REQUEST_BYTES = 10_000_000 # 10 MB — accommodates long agent conversation CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0 MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array +RESPONSES_AUTO_TRUNCATION_HISTORY_LIMIT = 100 +_COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary" def _coerce_port(value: Any, default: int = DEFAULT_PORT) -> int: @@ -166,6 +168,62 @@ def _coerce_request_bool(value: Any, default: bool = False) -> bool: return default +def _message_text_prefix(content: Any) -> str: + if isinstance(content, str): + return content[:128] + if not isinstance(content, list): + return "" + parts: List[str] = [] + for item in content[:4]: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + if sum(len(part) for part in parts) >= 128: + break + return "\n".join(parts)[:128] + + +def _is_compressed_summary_message(message: Any) -> bool: + if not isinstance(message, dict): + return False + if message.get(_COMPRESSED_SUMMARY_METADATA_KEY): + return True + prefix = _message_text_prefix(message.get("content")) + return prefix.startswith("[CONTEXT COMPACTION") or prefix.startswith("[CONTEXT SUMMARY]:") + + +def _auto_truncate_response_history( + conversation_history: List[Dict[str, Any]], + *, + limit: int = RESPONSES_AUTO_TRUNCATION_HISTORY_LIMIT, +) -> List[Dict[str, Any]]: + """Keep recent Responses history without dropping the compaction handoff.""" + if limit <= 0 or len(conversation_history) <= limit: + return conversation_history + + leading_summaries: List[Dict[str, Any]] = [] + first_conversation_index = 0 + for message in conversation_history: + if not _is_compressed_summary_message(message): + break + leading_summaries.append(message) + first_conversation_index += 1 + + if not leading_summaries: + return conversation_history[-limit:] + + preserved = leading_summaries[:limit] + remaining = limit - len(preserved) + if remaining <= 0: + return preserved + + recent_tail = conversation_history[first_conversation_index:][-remaining:] + return preserved + recent_tail + + def _normalize_chat_content( content: Any, *, _max_depth: int = 10, _depth: int = 0, ) -> str: @@ -3905,8 +3963,8 @@ class APIServerAdapter(BasePlatformAdapter): return web.json_response(_openai_error("No user message found in input"), status=400) # Truncation support - if body.get("truncation") == "auto" and len(conversation_history) > 100: - conversation_history = conversation_history[-100:] + if body.get("truncation") == "auto": + conversation_history = _auto_truncate_response_history(conversation_history) # Reuse session from previous_response_id chain so the dashboard # groups the entire conversation under one session entry. diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index ada851f86f54..0010b5a841f3 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -3537,6 +3537,48 @@ class TestTruncation: call_kwargs = mock_run.call_args.kwargs # History should be truncated to 100 assert len(call_kwargs["conversation_history"]) <= 100 + assert call_kwargs["conversation_history"][0]["content"] == "msg 50" + + @pytest.mark.asyncio + async def test_truncation_auto_preserves_leading_compaction_summary(self, adapter): + """truncation=auto must not discard the compacted-history handoff.""" + mock_result = {"final_response": "OK", "messages": [], "api_calls": 1} + + summary = { + "role": "user", + "content": "[CONTEXT COMPACTION — REFERENCE ONLY]\nEarlier work.", + "_compressed_summary": True, + } + long_history = [summary] + [ + {"role": "user", "content": f"msg {i}"} + for i in range(149) + ] + adapter._response_store.put("resp_summary", { + "response": {"id": "resp_summary", "object": "response"}, + "conversation_history": long_history, + "instructions": None, + }) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run: + mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}) + resp = await cli.post( + "/v1/responses", + json={ + "model": "hermes-agent", + "input": "follow up", + "previous_response_id": "resp_summary", + "truncation": "auto", + }, + ) + + assert resp.status == 200 + history = mock_run.call_args.kwargs["conversation_history"] + assert len(history) == 100 + assert history[0] == summary + assert history[1]["content"] == "msg 50" + assert history[-1]["content"] == "msg 148" @pytest.mark.asyncio async def test_no_truncation_keeps_full_history(self, adapter):