fix(compression,llm): keep the summary across mid-execution compression, ignore empty saved points, commit the head hash on success

Three review findings on the bounded-chain change.

Mid-execution compression rebuilt the conversation from the in-flight
messages, which after a turn-start reuse hold only the recent turns: the
summary living in the system prompt never reached the compressor, so the
new summary replaced the old one, and the persisted point's query_index
was relative to that shortened list. The summary the agent is running
under now rides into the synthetic conversation as its latest point
(query_index -1, so every in-flight query is new), for both the database
and the in-memory path, and the database path persists the index of the
saved conversation's last row.

Saved points with an empty summary, which earlier versions wrote, were
treated as reusable: get_compressed_context sliced the raw history away
and the effective token count made the conversation look small. Point
selection everywhere now takes the latest usable point (non-blank
summary, positive token count) and falls back to the raw history when
there is none.

The chained system-head hash was committed while building the request,
so a transport failure followed by the same-primary retry omitted a
changed system message. The hash is now staged per request and committed
only when the provider records the response.
This commit is contained in:
Alex committed 2026-09-05 10:39:58 +01:00
1 parent fdba261d11
commit dbee30a048
10 files changed
+500 -35

No files matched your search

@@ -10,6 +10,7 @@ from application.api.answer.services.compression.threshold_checker import (
from application.api.answer.services.compression.types import (
CompressionResult,
is_compression_summary_row,
latest_usable_compression_point,
)
from application.core.model_utils import (
get_api_key_for_provider,
@@ -96,10 +97,13 @@ class CompressionOrchestrator:
):
compression_meta = conversation.get("compression_metadata") or {}
points = compression_meta.get("compression_points") or []
if compression_meta.get("is_compressed") and points:
usable_point = latest_usable_compression_point(points)
if compression_meta.get("is_compressed") and usable_point:
# A saved summary applies to every later turn, not only
# the turn that made it (measured in prod: the full raw
# history was replayed on the very next turn).
# history was replayed on the very next turn). A point
# whose summary is empty is not one: the raw history
# stays.
summary, recent = self._read_only_service(
model_id
).get_compressed_context(conversation)
@@ -108,7 +112,7 @@ class CompressionOrchestrator:
recent,
last_compression_at=(
compression_meta.get("last_compression_at")
or points[-1].get("timestamp")
or usable_point.get("timestamp")
),
)
# No compression needed, return full history
@@ -145,6 +149,7 @@ class CompressionOrchestrator:
decoded_token: Dict[str, Any],
user_id: Optional[str] = None,
model_user_id: Optional[str] = None,
persist_query_index: Optional[int] = None,
) -> CompressionResult:
"""
Perform the actual compression operation.
@@ -211,18 +216,22 @@ class CompressionOrchestrator:
points = (conversation.get("compression_metadata") or {}).get(
"compression_points"
) or []
# Build on the latest point that can stand in for the history it
# covers; an empty saved summary does not move the start.
usable_point = latest_usable_compression_point(points)
start_index = 0
if points:
if usable_point:
try:
start_index = int(points[-1].get("query_index", -1)) + 1
start_index = int(usable_point.get("query_index", -1)) + 1
except (TypeError, ValueError):
start_index = 0
start_index = max(start_index, 0)
new_queries = [
q
for q in conversation.get("queries", [])[start_index:]
if not is_compression_summary_row(q)
]
if start_index > compress_up_to or not new_queries:
if usable_point and (start_index > compress_up_to or not new_queries):
logger.info(
f"No new queries since the last compression point for "
f"conversation {conversation_id}; reusing its summary"
@@ -231,23 +240,31 @@ class CompressionOrchestrator:
conversation
)
return CompressionResult.success_from_existing(
summary, recent, last_compression_at=points[-1].get("timestamp")
summary, recent, last_compression_at=usable_point.get("timestamp")
)
if usable_point:
covered = usable_point.get("query_index")
on_top = (
" on top of the summary carried into this turn"
if isinstance(covered, int) and covered < 0
else f" on top of the summary at query {covered}"
)
else:
on_top = ""
logger.info(
f"Initiating compression for conversation {conversation_id}: "
f"compressing queries {start_index}-{compress_up_to} of "
f"{queries_count}"
+ (
f" on top of the summary at query {points[-1].get('query_index')}"
if points
else ""
)
f"{queries_count}{on_top}"
)
# Perform compression and save to DB
metadata = compression_service.compress_and_save(
conversation_id, conversation, compress_up_to, start_index=start_index
conversation_id,
conversation,
compress_up_to,
start_index=start_index,
persist_query_index=persist_query_index,
)
logger.info(
@@ -284,6 +301,7 @@ class CompressionOrchestrator:
decoded_token: Dict[str, Any],
current_conversation: Optional[Dict[str, Any]] = None,
model_user_id: Optional[str] = None,
persist_query_index: Optional[int] = None,
) -> CompressionResult:
"""
Perform compression during tool execution.
@@ -325,6 +343,7 @@ class CompressionOrchestrator:
decoded_token,
user_id=user_id,
model_user_id=model_user_id,
persist_query_index=persist_query_index,
)
except Exception as e:
@@ -12,6 +12,7 @@ from application.api.answer.services.compression.token_counter import TokenCount
from application.api.answer.services.compression.types import (
CompressionMetadata,
is_compression_summary_row,
latest_usable_compression_point,
)
from application.core.settings import settings
@@ -53,6 +54,7 @@ class CompressionService:
conversation: Dict[str, Any],
compress_up_to_index: int,
start_index: int = 0,
persist_query_index: Optional[int] = None,
) -> CompressionMetadata:
"""
Compress conversation history up to specified index.
@@ -60,6 +62,12 @@ class CompressionService:
Args:
conversation: Full conversation document
compress_up_to_index: Last query index to include in compression
start_index: First query index that is new since the previous
compression point (earlier queries are already summarised).
persist_query_index: Index to record on the point when the
conversation passed in is a shortened, synthetic one (the
mid-execution path) and its positions do not match the
saved conversation's. Defaults to ``compress_up_to_index``.
Returns:
CompressionMetadata with compression details
@@ -104,11 +112,12 @@ class CompressionService:
)
previous_summary_tokens = 0
usable_point = latest_usable_compression_point(existing_compressions)
existing_compressions = [usable_point] if usable_point else []
if existing_compressions:
# Each point already folds in the ones before it, so only
# the latest matters — and it is part of what the new
# summary replaces.
existing_compressions = [existing_compressions[-1]]
# the latest usable one matters — and it is part of what
# the new summary replaces.
previous_summary_tokens = TokenCounter.count_message_tokens(
[{"content": existing_compressions[0].get("compressed_summary", "")}]
)
@@ -188,7 +197,11 @@ class CompressionService:
# Build compression metadata
compression_metadata = CompressionMetadata(
timestamp=datetime.now(timezone.utc),
query_index=compress_up_to_index,
query_index=(
persist_query_index
if persist_query_index is not None
else compress_up_to_index
),
compressed_summary=compressed_summary,
original_token_count=original_tokens,
compressed_token_count=compressed_tokens,
@@ -209,6 +222,7 @@ class CompressionService:
conversation: Dict[str, Any],
compress_up_to_index: int,
start_index: int = 0,
persist_query_index: Optional[int] = None,
) -> CompressionMetadata:
"""
Compress conversation and save to database.
@@ -231,7 +245,10 @@ class CompressionService:
# Perform compression
metadata = self.compress_conversation(
conversation, compress_up_to_index, start_index=start_index
conversation,
compress_up_to_index,
start_index=start_index,
persist_query_index=persist_query_index,
)
# Save to database
@@ -278,8 +295,16 @@ class CompressionService:
return None, []
return None, queries
# Get the most recent compression point
latest_compression = compression_points[-1]
# The most recent point that can actually stand in for the
# history it covers. An empty saved summary must not slice the
# raw history away and replace it with nothing.
latest_compression = latest_usable_compression_point(compression_points)
if latest_compression is None:
logger.warning(
"No usable compression point (saved summaries are empty) - "
"using full history"
)
return None, conversation.get("queries", []) or []
compressed_summary = latest_compression.get("compressed_summary")
last_compressed_index = latest_compression.get("query_index")
compressed_tokens = latest_compression.get("compressed_token_count", 0)
@@ -5,7 +5,10 @@ from typing import Any, Dict, List
from application.utils import num_tokens_from_string
from application.core.settings import settings
from application.api.answer.services.compression.types import is_compression_summary_row
from application.api.answer.services.compression.types import (
is_compression_summary_row,
latest_usable_compression_point,
)
logger = logging.getLogger(__name__)
@@ -148,7 +151,11 @@ class TokenCounter:
points = metadata.get("compression_points") or []
if not (metadata.get("is_compressed") and points):
return TokenCounter.count_query_tokens(queries)
latest = points[-1] or {}
latest = latest_usable_compression_point(points)
if latest is None:
# Only unusable (empty) points: the raw history is what the
# next turn will replay.
return TokenCounter.count_query_tokens(queries)
try:
last_index = int(latest.get("query_index", -1))
except (TypeError, ValueError):
@@ -12,6 +12,36 @@ COMPRESSION_SUMMARY_PROMPT = "[Context Compression Summary]"
COMPRESSION_SUMMARY_MARKER = "compression_summary"
def is_usable_compression_point(point: Any) -> bool:
"""True when a saved compression point can stand in for the history it
covers: a non-blank summary and, when recorded, a positive token count.
Earlier versions persisted points with an empty summary (a 494k-token
conversation was "compressed" to 0 tokens in production); reusing such a
point would drop the history it covers and replace it with nothing.
"""
if not isinstance(point, dict):
return False
summary = point.get("compressed_summary")
if not isinstance(summary, str) or not summary.strip():
return False
count = point.get("compressed_token_count")
if count is None:
return True
try:
return int(count) > 0
except (TypeError, ValueError):
return False
def latest_usable_compression_point(points: Any) -> Optional[Dict[str, Any]]:
"""The most recent point that ``is_usable_compression_point``, or None."""
for point in reversed(list(points or [])):
if is_usable_compression_point(point):
return point
return None
def is_compression_summary_row(query: Any) -> bool:
"""True for the visible summary row written by ``append_compression_message``.
+61 -7
View File
@@ -592,6 +592,42 @@ class LLMHandler(ABC):
include_tool_calls=include_tool_calls,
)
@staticmethod
def _carry_current_summary(conversation: Dict, agent) -> Dict:
"""Attach the summary the agent is already running under as the
synthetic conversation's latest compression point.
The synthetic conversation is rebuilt from the in-flight messages,
which hold only the turns replayed this turn plus the current one;
the earlier history exists solely as the summary in the system
prompt. Without this point the compressor would summarise the recent
turns alone and the new summary would replace, not extend, the old
one. ``query_index`` is -1 because every synthetic query is newer
than the summary.
"""
summary = getattr(agent, "compressed_summary", None)
if not conversation or not summary or not str(summary).strip():
return conversation
from application.api.answer.services.compression.token_counter import (
TokenCounter,
)
tokens = TokenCounter.count_message_tokens([{"content": summary}])
conversation["compression_metadata"] = {
"is_compressed": True,
"compression_points": [
{
"timestamp": getattr(agent, "last_compression_at", None),
"query_index": -1,
"compressed_summary": summary,
"compressed_token_count": tokens,
"original_token_count": tokens,
"carried": True,
}
],
}
return conversation
@staticmethod
def _reset_responses_chain(agent, metadata) -> None:
"""After a compression the rebuilt local messages ARE the context.
@@ -639,11 +675,22 @@ class LLMHandler(ABC):
agent.conversation_id, agent.initial_user_id
)
persist_query_index = None
if conversation:
# Merge current in-flight messages (including tool calls)
# Merge current in-flight messages (including tool calls).
# The synthetic conversation holds only the turns replayed
# this turn plus the in-flight one, so the summary already in
# the system prompt rides along as its latest point, and the
# persisted point is indexed against the SAVED conversation,
# not this shortened list.
saved_count = len(conversation.get("queries") or [])
if saved_count:
persist_query_index = saved_count - 1
conversation_from_msgs = self._build_conversation_from_messages(messages)
if conversation_from_msgs:
conversation = conversation_from_msgs
conversation = self._carry_current_summary(
conversation_from_msgs, agent
)
else:
logger.warning(
"Could not load conversation for compression; attempting in-memory compression"
@@ -661,6 +708,7 @@ class LLMHandler(ABC):
model_id=agent.model_id,
decoded_token=getattr(agent, "decoded_token", {}),
current_conversation=conversation,
persist_query_index=persist_query_index,
)
if not result.success:
@@ -767,6 +815,8 @@ class LLMHandler(ABC):
"Cannot perform in-memory compression: no user/assistant turns found"
)
return False, None
# The summary already in play must survive this compression too.
conversation = self._carry_current_summary(conversation, agent)
compression_model = (
settings.COMPRESSION_MODEL_OVERRIDE
@@ -843,14 +893,18 @@ class LLMHandler(ABC):
return True, pruned
return False, None
# Attach metadata to synthetic conversation
conversation["compression_metadata"] = {
"is_compressed": True,
"compression_points": [metadata.to_dict()],
# Attach metadata to a copy of the synthetic conversation (the
# one handed to the compressor keeps its carried point).
context_conversation = {
**conversation,
"compression_metadata": {
"is_compressed": True,
"compression_points": [metadata.to_dict()],
},
}
compressed_summary, recent_queries = (
compression_service.get_compressed_context(conversation)
compression_service.get_compressed_context(context_conversation)
)
agent.compressed_summary = compressed_summary
+22 -4
View File
@@ -264,6 +264,11 @@ class OpenAILLM(BaseLLM):
# transcript instead of deduping it, so a chained round only carries
# the head when it changed.
self._chain_system_hash = None
# The head hash a request in flight would establish; it becomes
# ``_chain_system_hash`` only once the provider records the response,
# so a failed request leaves the chain's head unchanged and a retry
# re-sends the head.
self._pending_system_hash = None
# Opaque per-user prompt-cache routing key, set by the agent per call.
self._prompt_cache_key = None
# Files-API ids for inline ``file_data`` content parts already
@@ -327,6 +332,7 @@ class OpenAILLM(BaseLLM):
self._last_reasoning_items = list(state.get("reasoning_items") or [])
self._reasoning_for_calls = dict(state.get("reasoning_for_calls") or {})
self._chain_system_hash = state.get("system_hash")
self._pending_system_hash = None
return True
def start_responses_turn(self) -> None:
@@ -337,6 +343,7 @@ class OpenAILLM(BaseLLM):
self._last_response_call_ids = set()
self._imported_response_id = None
self._chain_system_hash = None
self._pending_system_hash = None
self._last_finish_reason = None
def _resolve_file_part(self, item):
@@ -1142,7 +1149,9 @@ class OpenAILLM(BaseLLM):
"""
chained = bool(previous_response_id and settings.OPENAI_RESPONSES_STORE)
if not chained:
self._chain_system_hash = self._system_fingerprint(messages)
# The full head goes out; it becomes the chain's head only once
# the provider records the response.
self._pending_system_hash = self._system_fingerprint(messages)
return self._to_responses_input(messages, chained=False), None
trimmed = self._trim_for_previous_response(messages)
@@ -1154,8 +1163,12 @@ class OpenAILLM(BaseLLM):
# unchanged head stays home. After a compression the head also
# carries the summary, which made every tool round re-append it.
trimmed = [m for m in trimmed if m.get("role") != "system"]
elif head_hash:
self._chain_system_hash = head_hash
self._pending_system_hash = self._chain_system_hash
else:
# A changed (or first) head is sent; only a recorded response
# makes it the chain's head, so a failed request and its retry
# still carry it.
self._pending_system_hash = head_hash or self._chain_system_hash
input_items = self._to_responses_input(trimmed, chained=True)
expected = set(self._last_response_call_ids or ())
if expected:
@@ -1189,7 +1202,7 @@ class OpenAILLM(BaseLLM):
"request the provider would reject",
sorted(missing),
)
self._chain_system_hash = self._system_fingerprint(messages)
self._pending_system_hash = self._system_fingerprint(messages)
return self._to_responses_input(messages, chained=False), None
return input_items, previous_response_id
@@ -1409,6 +1422,11 @@ class OpenAILLM(BaseLLM):
rid = getattr(response, "id", None)
if rid:
self._last_response_id = rid
# The provider recorded this request, so the head it carried (or
# kept) is now the chain's head.
if self._pending_system_hash is not None:
self._chain_system_hash = self._pending_system_hash
self._pending_system_hash = None
self._last_response_call_ids = self._function_call_ids(response)
usage = getattr(response, "usage", None)
if usage is not None: