fix(api-server): preserve compaction summaries on auto truncation

Fixes #55224
This commit is contained in:
luyifan 2026-06-30 06:04:14 +08:00 committed by Teknium
parent 1c21e96ed0
commit 3807df7a06
2 changed files with 102 additions and 2 deletions

View file

@ -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.

View file

@ -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):