mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(api-server): preserve compaction summaries on auto truncation
Fixes #55224
This commit is contained in:
parent
1c21e96ed0
commit
3807df7a06
2 changed files with 102 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue