fix(llm,compression): bound cross-turn Responses chaining and make compression stick

In store mode every user turn chained onto the previous response, so the
provider's stored transcript grew without bound (measured: 889k prompt
tokens for a 37k-token saved history) while every local guard, the
compression pipeline included, measured the saved history. Each chained
tool round also re-sent the system message, which the server appends rather
than dedupes, and a saved compression point was applied exactly once, in the
turn that made it.

Chaining is now bounded. A turn starts from the saved history when the
previous turn's reported prompt reached the chain budget (default: the
model's context window), when the conversation was compressed after that
turn was produced, or when OPENAI_RESPONSES_CHAIN_ACROSS_TURNS is off.
Chained rounds omit an unchanged system head (hash carried in the persisted
Responses state). truncation="auto" is available behind a setting as a
backstop against a chain that outgrows the model's window.

Compression: a saved point is applied at every turn start; the threshold
counts the summary plus the queries after the point instead of the raw
history; re-compression summarises only the tail on top of the last point;
the mid-execution path marks itself persisted and resets the provider chain
so the rebuilt messages are the context; an empty summary is rejected; the
visible "[Context Compression Summary]" rows are no longer replayed as
history; appending the same point twice is a no-op.

Cache hints: a per-user prompt_cache_key and an optional
prompt_cache_retention on Responses API calls.

Measured on Azure with the same client shape as production (stateless
OpenAI client, server-side tools, PDF part): tokens billed on the sixth turn
fell from 58k to 35k, tool rounds add tens of tokens instead of ~2.8k, the
turn after a compression reused the saved summary in under two seconds
instead of re-summarising, and the round after a mid-execution compression
started from the compressed context instead of the full stored transcript.
This commit is contained in:
Alex committed 2026-09-05 00:28:51 +01:00
1 parent 4b56e56649
commit 04d358ab69
19 files changed
+1099 -38

No files matched your search

+106 -3
View File
@@ -2,6 +2,7 @@ import json
import logging
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any, Dict, Generator, List, Optional
from application.agents.tool_executor import (
@@ -32,6 +33,39 @@ from application.logging import build_stack_data, log_activity, LogContext
logger = logging.getLogger(__name__)
def _parse_epoch(value: Any) -> Optional[datetime]:
"""Parse a compression timestamp as stored anywhere it lands.
``compression_points[].timestamp`` is written as an ISO datetime,
``compression_metadata.last_compression_at`` as ``str(datetime)`` (space
separator), and the per-turn ``compression_epoch`` as whichever of those
the agent carried. All must compare equal.
"""
if value is None:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
text = str(value).strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def _epoch_text(value: Any) -> Optional[str]:
"""Serialize a compression timestamp for message metadata."""
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
class BaseAgent(ABC):
# Inert defaults: an instance built without __init__ still resolves these.
_guardrail_engine = None
@@ -64,6 +98,7 @@ class BaseAgent(ABC):
limited_request_mode: Optional[bool] = False,
request_limit: Optional[int] = settings.DEFAULT_AGENT_LIMITS["request_limit"],
compressed_summary: Optional[str] = None,
last_compression_at: Optional[Any] = None,
llm=None,
llm_handler=None,
tool_executor: Optional[ToolExecutor] = None,
@@ -160,6 +195,10 @@ class BaseAgent(ABC):
self.limited_request_mode = limited_request_mode
self.request_limit = request_limit
self.compressed_summary = compressed_summary
# When the conversation was last compressed (any path). Stamped onto
# each turn's metadata so a later turn never chains onto a Responses
# id produced before a compression.
self.last_compression_at = last_compression_at
self.current_token_count = 0
self.context_limit_reached = False
self.conversation_id: Optional[str] = None
@@ -314,13 +353,25 @@ class BaseAgent(ABC):
**stored_metadata,
"responses_state": state,
"usage": getattr(self.llm, "_last_usage", None),
"compression_epoch": _epoch_text(getattr(self, "last_compression_at", None)),
}
metadata = {key: value for key, value in metadata.items() if value is not None}
if metadata:
yield {"metadata": metadata}
def _previous_response_id(self) -> Optional[str]:
"""Return the immediately preceding compatible Responses API id."""
"""Return the preceding turn's Responses id when chaining onto it is safe.
Chaining keeps the provider's stored transcript (and its prompt cache)
warm across turns, but that transcript is invisible to every local
guard, so it is bounded. No chaining across turns when the operator
turned it off, when the previous turn's reported prompt already
reached the chain budget (default: the model's context window), or
when the conversation was compressed after that turn was produced —
the compressed local history is the context then, not the server's.
"""
if not getattr(settings, "OPENAI_RESPONSES_CHAIN_ACROSS_TURNS", True):
return None
if not self.chat_history:
return None
turn = self.chat_history[-1]
@@ -333,13 +384,49 @@ class BaseAgent(ABC):
current_chain_key = (
chain_key_factory() if callable(chain_key_factory) else None
)
if (
if not (
current_chain_key
and meta.get("response_chain_key") == current_chain_key
and meta.get("response_id")
):
return None
current_epoch = _parse_epoch(getattr(self, "last_compression_at", None))
if current_epoch is not None:
turn_epoch = _parse_epoch(meta.get("compression_epoch"))
if turn_epoch is None or turn_epoch < current_epoch:
logger.info(
"Responses chain reset: the conversation was compressed after "
"the previous turn; starting from the compressed local history"
)
return None
usage = meta.get("usage") if isinstance(meta.get("usage"), dict) else {}
try:
prompt_tokens = int(usage.get("prompt_tokens") or 0)
except (TypeError, ValueError):
prompt_tokens = 0
if not prompt_tokens:
# No provider-reported usage on the previous turn (older rows,
# estimate-only providers): nothing to bound against.
return meta["response_id"]
return None
budget = getattr(settings, "OPENAI_RESPONSES_CHAIN_BUDGET_TOKENS", None)
if not budget:
from application.core.model_utils import get_token_limit
budget = get_token_limit(
getattr(self, "model_id", None),
user_id=getattr(self, "model_user_id", None) or getattr(self, "user", None),
)
if budget and prompt_tokens >= int(budget):
logger.info(
"Responses chain budget reached (%s >= %s prompt tokens on the "
"previous turn); starting this turn from the local history",
prompt_tokens,
budget,
)
return None
return meta["response_id"]
def _previous_responses_state(self) -> Optional[Dict[str, Any]]:
"""Return continuity state from the immediately preceding turn."""
@@ -1238,6 +1325,22 @@ class BaseAgent(ABC):
previous_response_id = self._previous_response_id()
if previous_response_id:
gen_kwargs["previous_response_id"] = previous_response_id
if not preserve_responses_state and hasattr(
self.llm, "_chain_system_hash"
):
# The system head the chain last saw, so the first
# chained call of this turn skips an unchanged system
# message instead of appending a copy server-side.
state = self._previous_responses_state() or {}
self.llm._chain_system_hash = state.get("system_hash")
if hasattr(self.llm, "_prompt_cache_key"):
# Route a user's calls to the same cache shard. Keyed by user, not
# conversation: a new conversation has no id until its first turn
# is saved, and a key that appears on turn two would look up a
# different shard from the one turn one populated.
self.llm._prompt_cache_key = (
getattr(self, "initial_user_id", None) or getattr(self, "user", None)
)
# Forward OpenAI sampling params (temperature, max_tokens, top_p, ...).
if self.llm_params:
@@ -1,14 +1,13 @@
"""High-level compression orchestration."""
import logging
from typing import Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional
from application.api.answer.services.compression.service import CompressionService
from application.api.answer.services.compression.threshold_checker import (
CompressionThresholdChecker,
)
from application.api.answer.services.compression.types import CompressionResult
from application.api.answer.services.conversation_service import ConversationService
from application.core.model_utils import (
get_api_key_for_provider,
get_provider_from_model_id,
@@ -16,6 +15,9 @@ from application.core.model_utils import (
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
if TYPE_CHECKING: # pragma: no cover - annotation only
from application.api.answer.services.conversation_service import ConversationService
logger = logging.getLogger(__name__)
@@ -29,7 +31,7 @@ class CompressionOrchestrator:
def __init__(
self,
conversation_service: ConversationService,
conversation_service: "ConversationService",
threshold_checker: Optional[CompressionThresholdChecker] = None,
):
"""
@@ -89,6 +91,23 @@ class CompressionOrchestrator:
current_query_tokens,
user_id=registry_user_id,
):
compression_meta = conversation.get("compression_metadata") or {}
points = compression_meta.get("compression_points") or []
if compression_meta.get("is_compressed") and points:
# 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).
summary, recent = self._read_only_service(
model_id
).get_compressed_context(conversation)
return CompressionResult.success_from_existing(
summary,
recent,
last_compression_at=(
compression_meta.get("last_compression_at")
or points[-1].get("timestamp")
),
)
# No compression needed, return full history
queries = conversation.get("queries", [])
return CompressionResult.success_no_compression(queries)
@@ -109,6 +128,12 @@ class CompressionOrchestrator:
)
return CompressionResult.failure(str(e))
def _read_only_service(self, model_id: str) -> CompressionService:
"""A service for applying saved compression points; no LLM is involved."""
return CompressionService(
llm=None, model_id=model_id, conversation_service=self.conversation_service
)
def _perform_compression(
self,
conversation_id: str,
@@ -171,7 +196,8 @@ class CompressionOrchestrator:
conversation_service=self.conversation_service,
)
# Compress all queries up to the latest
# Compress everything since the last compression point up to the
# latest query; the earlier queries are already in that point.
queries_count = len(conversation.get("queries", []))
compress_up_to = queries_count - 1
@@ -179,14 +205,41 @@ class CompressionOrchestrator:
logger.warning("No queries to compress")
return CompressionResult.success_no_compression([])
points = (conversation.get("compression_metadata") or {}).get(
"compression_points"
) or []
start_index = 0
if points:
try:
start_index = int(points[-1].get("query_index", -1)) + 1
except (TypeError, ValueError):
start_index = 0
if start_index > compress_up_to:
logger.info(
f"No new queries since the last compression point for "
f"conversation {conversation_id}; reusing its summary"
)
summary, recent = compression_service.get_compressed_context(
conversation
)
return CompressionResult.success_from_existing(
summary, recent, last_compression_at=points[-1].get("timestamp")
)
logger.info(
f"Initiating compression for conversation {conversation_id}: "
f"compressing all {queries_count} queries (0-{compress_up_to})"
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 ""
)
)
# Perform compression and save to DB
metadata = compression_service.compress_and_save(
conversation_id, conversation, compress_up_to
conversation_id, conversation, compress_up_to, start_index=start_index
)
logger.info(
@@ -11,6 +11,7 @@ from application.api.answer.services.compression.prompt_builder import (
from application.api.answer.services.compression.token_counter import TokenCounter
from application.api.answer.services.compression.types import (
CompressionMetadata,
is_compression_summary_row,
)
from application.core.settings import settings
@@ -51,6 +52,7 @@ class CompressionService:
self,
conversation: Dict[str, Any],
compress_up_to_index: int,
start_index: int = 0,
) -> CompressionMetadata:
"""
Compress conversation history up to specified index.
@@ -74,8 +76,14 @@ class CompressionService:
f"(conversation has {len(queries)} queries)"
)
# Get queries to compress
queries_to_compress = queries[: compress_up_to_index + 1]
if start_index < 0 or start_index > compress_up_to_index:
raise ValueError(
f"Nothing to compress: start_index {start_index} is past "
f"compress_up_to_index {compress_up_to_index}"
)
# Only the queries after the previous compression point are new;
# the earlier ones are already inside that point's summary.
queries_to_compress = queries[start_index : compress_up_to_index + 1]
# Check if there are existing compressions. ``compression_metadata``
# is a nullable JSONB column, so a never-compressed conversation
@@ -85,14 +93,26 @@ class CompressionService:
"compression_points", []
)
previous_summary_tokens = 0
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]]
previous_summary_tokens = TokenCounter.count_message_tokens(
[{"content": existing_compressions[0].get("compressed_summary", "")}]
)
logger.info(
f"Found {len(existing_compressions)} previous compression(s) - "
f"will incorporate into new summary"
"Found a previous compression point (query %s) - "
"the new summary builds on it",
existing_compressions[0].get("query_index"),
)
# Calculate original token count
original_tokens = TokenCounter.count_query_tokens(queries_to_compress)
# Calculate original token count: everything the new summary replaces
original_tokens = (
TokenCounter.count_query_tokens(queries_to_compress)
+ previous_summary_tokens
)
# Log tool call stats
self._log_tool_call_stats(queries_to_compress)
@@ -105,7 +125,7 @@ class CompressionService:
# Call LLM to generate compression
logger.info(
f"Starting compression: {len(queries_to_compress)} queries "
f"(messages 0-{compress_up_to_index}, {original_tokens} tokens) "
f"(messages {start_index}-{compress_up_to_index}, {original_tokens} tokens) "
f"using model {self.model_id}"
)
@@ -126,6 +146,13 @@ class CompressionService:
[{"content": compressed_summary}]
)
# An empty summary is not a compression: it replaced a 494k-token
# conversation with nothing in prod (2026-08-27) while reporting
# success.
if not compressed_summary.strip() or compressed_tokens <= 0:
raise ValueError(
"Compression produced an empty summary; keeping original history"
)
# Calculate compression ratio
compression_ratio = (
original_tokens / compressed_tokens if compressed_tokens > 0 else 0
@@ -171,6 +198,7 @@ class CompressionService:
conversation_id: str,
conversation: Dict[str, Any],
compress_up_to_index: int,
start_index: int = 0,
) -> CompressionMetadata:
"""
Compress conversation and save to database.
@@ -192,7 +220,9 @@ class CompressionService:
)
# Perform compression
metadata = self.compress_conversation(conversation, compress_up_to_index)
metadata = self.compress_conversation(
conversation, compress_up_to_index, start_index=start_index
)
# Save to database
self.conversation_service.update_compression_metadata(
@@ -248,7 +278,13 @@ class CompressionService:
# Get only messages after compression point
queries = conversation.get("queries", [])
total_queries = len(queries)
recent_queries = queries[last_compressed_index + 1 :]
# The visible summary rows appended after a compression are not
# history: their content already rides in the system prompt.
recent_queries = [
q
for q in queries[last_compressed_index + 1 :]
if not is_compression_summary_row(q)
]
logger.info(
f"Using compressed context: summary ({compressed_tokens} tokens, "
@@ -46,8 +46,9 @@ class CompressionThresholdChecker:
True if tokens >= threshold% of context window
"""
try:
# Calculate total tokens in conversation
total_tokens = TokenCounter.count_conversation_tokens(conversation)
# What the next turn will replay: summary + queries after the
# last compression point, or the raw history when never compressed.
total_tokens = TokenCounter.count_effective_conversation_tokens(conversation)
total_tokens += current_query_tokens
# Get context window limit for model
@@ -5,6 +5,7 @@ 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
logger = logging.getLogger(__name__)
@@ -131,3 +132,36 @@ class TokenCounter:
except Exception as e:
logger.error(f"Error calculating conversation tokens: {str(e)}")
return 0
@staticmethod
def count_effective_conversation_tokens(conversation: Dict[str, Any]) -> int:
"""Tokens the next turn will actually replay.
The latest summary plus the queries after its compression point, or
everything when the conversation was never compressed.
``count_conversation_tokens`` counts the raw history regardless, which
is what made every turn after a compression trigger it again.
"""
try:
queries = conversation.get("queries", []) or []
metadata = conversation.get("compression_metadata") or {}
points = metadata.get("compression_points") or []
if not (metadata.get("is_compressed") and points):
return TokenCounter.count_query_tokens(queries)
latest = points[-1] or {}
try:
last_index = int(latest.get("query_index", -1))
except (TypeError, ValueError):
last_index = -1
recent = [
q
for q in queries[last_index + 1 :]
if not is_compression_summary_row(q)
]
summary_tokens = TokenCounter.count_message_tokens(
[{"content": latest.get("compressed_summary") or ""}]
)
return summary_tokens + TokenCounter.count_query_tokens(recent)
except Exception as e:
logger.error(f"Error calculating effective conversation tokens: {str(e)}")
return 0
@@ -4,6 +4,16 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Optional
# ``prompt`` of the visible summary row appended after a compression. These
# rows are display-only: their content already reaches the model through the
# system prompt (``compressed_summary``), so history replay skips them.
COMPRESSION_SUMMARY_PROMPT = "[Context Compression Summary]"
def is_compression_summary_row(query: Any) -> bool:
"""True for the visible summary row written by ``append_compression_message``."""
return isinstance(query, dict) and query.get("prompt") == COMPRESSION_SUMMARY_PROMPT
@dataclass
class CompressionMetadata:
@@ -42,10 +52,17 @@ class CompressionResult:
metadata: Optional[CompressionMetadata] = None
error: Optional[str] = None
compression_performed: bool = False
# When the conversation was last compressed — the point this turn's
# history reflects, whether it was made now or reused from the DB.
last_compression_at: Optional[Any] = None
@classmethod
def success_with_compression(
cls, summary: str, queries: List[Dict], metadata: CompressionMetadata
cls,
summary: str,
queries: List[Dict],
metadata: CompressionMetadata,
last_compression_at: Optional[Any] = None,
) -> "CompressionResult":
"""Create a successful result with compression."""
return cls(
@@ -54,6 +71,31 @@ class CompressionResult:
recent_queries=queries,
metadata=metadata,
compression_performed=True,
last_compression_at=(
last_compression_at
if last_compression_at is not None
else getattr(metadata, "timestamp", None)
),
)
@classmethod
def success_from_existing(
cls,
summary: Optional[str],
queries: List[Dict],
last_compression_at: Optional[Any] = None,
) -> "CompressionResult":
"""A previously saved compression point applied to this turn.
No LLM call was made; the summary and the queries after its point
are what the turn replays instead of the raw history.
"""
return cls(
success=True,
compressed_summary=summary,
recent_queries=queries,
compression_performed=False,
last_compression_at=last_compression_at,
)
@classmethod
@@ -81,6 +123,8 @@ class CompressionResult:
"""
out: List[Dict[str, str]] = []
for q in self.recent_queries:
if is_compression_summary_row(q):
continue
entry: Dict[str, str] = {
"prompt": q["prompt"],
"response": q["response"],
@@ -14,6 +14,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import text as sql_text
from application.core.settings import settings
from application.api.answer.services.compression.types import COMPRESSION_SUMMARY_PROMPT
from application.storage.db.base_repository import looks_like_uuid
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.conversations import (
@@ -603,7 +604,7 @@ class ConversationService:
str(conv["id"]) if conv is not None else conversation_id
)
repo.append_message(conv_pg_id, {
"prompt": "[Context Compression Summary]",
"prompt": COMPRESSION_SUMMARY_PROMPT,
"response": summary,
"thought": "",
"sources": [],
@@ -8,6 +8,7 @@ from application.agents.agent_creator import AgentCreator
from application.agents.default_tools import synthesized_default_tools
from application.api.answer.services.compression import CompressionOrchestrator
from application.api.answer.services.compression.token_counter import TokenCounter
from application.api.answer.services.compression.types import is_compression_summary_row
from application.api.answer.services.conversation_service import ConversationService
from application.prompts.composer import compose_preset, is_composed_preset
from application.api.answer.services.prompt_renderer import (
@@ -146,6 +147,9 @@ class StreamProcessor:
self._required_tool_actions: Optional[Dict[str, Set[Optional[str]]]] = None
self.compressed_summary: Optional[str] = None
self.compressed_summary_tokens: int = 0
# When the conversation's history was last compressed (DB point or
# one made this turn); the agent stamps it on the turn's metadata.
self.last_compression_at: Optional[Any] = None
self._agent_data: Optional[Dict[str, Any]] = None
def initialize(self):
@@ -313,6 +317,7 @@ class StreamProcessor:
),
}
for query in conversation.get("queries", [])
if not is_compression_summary_row(query)
]
else:
# model_user_id keeps history trim aligned with the BYOM's
@@ -355,10 +360,11 @@ class StreamProcessor:
),
}
for query in conversation.get("queries", [])
if not is_compression_summary_row(query)
]
return
if result.compression_performed and result.compressed_summary:
if result.compressed_summary:
self.compressed_summary = result.compressed_summary
self.compressed_summary_tokens = TokenCounter.count_message_tokens(
[{"content": result.compressed_summary}]
@@ -366,11 +372,17 @@ class StreamProcessor:
logger.info(
f"Using compressed summary ({self.compressed_summary_tokens} tokens) "
f"+ {len(result.recent_queries)} recent messages"
+ ("" if result.compression_performed else " (saved compression point)")
)
self.last_compression_at = result.last_compression_at
self.history = result.as_history()
# Preserve metadata from recent queries (as_history only has prompt/response)
recent = result.recent_queries if result.recent_queries else conversation.get("queries", [])
recent = [
q
for q in (result.recent_queries or conversation.get("queries", []))
if not is_compression_summary_row(q)
]
for i, entry in enumerate(self.history):
# Match by index from the end of recent queries
offset = len(recent) - len(self.history)
@@ -1741,6 +1753,7 @@ class StreamProcessor:
"llm_params": self.data.get("llm_params") or {},
"multimodal_content": request_multimodal,
"compressed_summary": self.compressed_summary,
"last_compression_at": self.last_compression_at,
"llm": llm,
"llm_handler": llm_handler,
"tool_executor": tool_executor,
+15
View File
@@ -369,6 +369,21 @@ class Settings(BaseSettings):
# True persists Responses API calls server-side so previous_response_id can chain turns.
# False keeps them stateless, carrying reasoning across the tool loop as encrypted items.
OPENAI_RESPONSES_STORE: bool = False
# Cross-turn ``previous_response_id`` chaining (store mode only). The
# chained transcript lives on the provider and is invisible to every
# local guard, so it is bounded: a turn starts from the local history
# when the previous turn's reported prompt already reached the budget
# (default: the model's context window) or when the conversation was
# compressed after that turn was produced.
OPENAI_RESPONSES_CHAIN_ACROSS_TURNS: bool = True
OPENAI_RESPONSES_CHAIN_BUDGET_TOKENS: Optional[int] = None
# ``truncation: "auto"`` lets the provider drop middle items instead of
# failing every request once a chain exceeds the model's native window.
OPENAI_RESPONSES_TRUNCATION_AUTO: bool = False
# Prompt-cache hints on the Responses API: route a user's calls to the
# same cache shard, and request extended retention where offered.
OPENAI_PROMPT_CACHE_KEY: bool = True
OPENAI_PROMPT_CACHE_RETENTION: Optional[str] = None
OPENAI_REASONING_SUMMARY: str = "auto"
# Lets OpenAI-compatible clients identify a logical chat by session header, which
+35 -5
View File
@@ -592,6 +592,22 @@ class LLMHandler(ABC):
include_tool_calls=include_tool_calls,
)
@staticmethod
def _reset_responses_chain(agent, metadata) -> None:
"""After a compression the rebuilt local messages ARE the context.
Forget the provider-side chain so the next call is not prepended with
the uncompressed transcript (prod: 236k input tokens billed for a
four-message rebuilt input), and stamp the compression epoch so later
turns do not chain onto pre-compression responses either.
"""
starter = getattr(getattr(agent, "llm", None), "start_responses_turn", None)
if callable(starter):
starter()
timestamp = getattr(metadata, "timestamp", None)
if timestamp is not None:
agent.last_compression_at = timestamp
def _perform_mid_execution_compression(
self, agent, messages: List[Dict]
) -> tuple[bool, Optional[List[Dict]]]:
@@ -661,11 +677,18 @@ class LLMHandler(ABC):
logger.warning("Compression not performed")
return False, None
# Check if compression actually reduced tokens
# Check if compression actually reduced tokens — and produced
# anything at all: a 0-token "summary" replaced a 494k-token
# conversation with nothing in prod (2026-08-27).
if result.metadata:
if result.metadata.compressed_token_count >= result.metadata.original_token_count:
if (
result.metadata.compressed_token_count <= 0
or result.metadata.compressed_token_count
>= result.metadata.original_token_count
):
logger.warning(
"Compression did not reduce token count; falling back to minimal pruning"
"Compression did not reduce token count (or produced an "
"empty summary); falling back to minimal pruning"
)
pruned = self._prune_messages_minimal(messages)
if pruned:
@@ -685,10 +708,15 @@ class LLMHandler(ABC):
agent.conversation_id, result.metadata.to_dict()
)
# Update agent's compressed summary for downstream persistence
# Update agent's compressed summary for downstream persistence.
# ``compress_mid_execution`` already wrote the compression point
# and the row above is the visible summary, so this IS saved —
# leaving the flag False made the route persist both again
# (prod: every mid-execution compression stored twice).
agent.compressed_summary = result.compressed_summary
agent.compression_metadata = result.metadata.to_dict() if result.metadata else None
agent.compression_saved = False
agent.compression_saved = True
self._reset_responses_chain(agent, result.metadata)
# Reset the context limit flag so tools can continue
agent.context_limit_reached = False
@@ -827,7 +855,9 @@ class LLMHandler(ABC):
agent.compressed_summary = compressed_summary
agent.compression_metadata = metadata.to_dict()
# Nothing reached the DB on this path; the route persists once.
agent.compression_saved = False
self._reset_responses_chain(agent, metadata)
agent.context_limit_reached = False
agent.current_token_count = 0
+49 -3
View File
@@ -259,6 +259,13 @@ class OpenAILLM(BaseLLM):
# fallback restream of the already-delivered answer.
self._stream_reached_finish = False
self._imported_response_id = None
# Hash of the system head the current Responses chain already holds.
# The server appends a re-sent system message after the stored
# transcript instead of deduping it, so a chained round only carries
# the head when it changed.
self._chain_system_hash = None
# Prompt-cache routing key (the user id), set by the agent per call.
self._prompt_cache_key = None
# Files-API ids for inline ``file_data`` content parts already
# uploaded, keyed by content hash. First-line cache for the
# in-request tool loop; the Redis-backed cross-request cache
@@ -304,6 +311,7 @@ class OpenAILLM(BaseLLM):
"call_ids": sorted(self._last_response_call_ids or ()),
"reasoning_items": self._last_reasoning_items,
"reasoning_for_calls": self._reasoning_for_calls,
"system_hash": self._chain_system_hash,
}
def import_responses_state(self, state: dict | None) -> bool:
@@ -318,6 +326,7 @@ class OpenAILLM(BaseLLM):
self._last_response_call_ids = set(state.get("call_ids") or ())
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")
return True
def start_responses_turn(self) -> None:
@@ -327,6 +336,7 @@ class OpenAILLM(BaseLLM):
self._last_response_id = None
self._last_response_call_ids = set()
self._imported_response_id = None
self._chain_system_hash = None
self._last_finish_reason = None
def _resolve_file_part(self, item):
@@ -1099,6 +1109,19 @@ class OpenAILLM(BaseLLM):
]
return head + carried + messages[last_assistant + 1:]
@staticmethod
def _system_fingerprint(messages):
"""Hash of the system messages' content; ``None`` when there are none."""
parts = [
m.get("content")
for m in messages
if isinstance(m, dict) and m.get("role") == "system"
]
if not parts:
return None
encoded = json.dumps(parts, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _build_responses_input(self, messages, previous_response_id):
"""Build the Responses ``input`` list, honouring store-mode chaining.
@@ -1119,11 +1142,21 @@ class OpenAILLM(BaseLLM):
"""
chained = bool(previous_response_id and settings.OPENAI_RESPONSES_STORE)
if not chained:
self._chain_system_hash = self._system_fingerprint(messages)
return self._to_responses_input(messages, chained=False), None
input_items = self._to_responses_input(
self._trim_for_previous_response(messages), chained=True
)
trimmed = self._trim_for_previous_response(messages)
head_hash = self._system_fingerprint(trimmed)
if head_hash and head_hash == self._chain_system_hash:
# The server appends a re-sent system message after the stored
# transcript instead of replacing it (measured on Azure: 11,239
# vs 5,635 input tokens for the same chained turn), so an
# 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
input_items = self._to_responses_input(trimmed, chained=True)
expected = set(self._last_response_call_ids or ())
if expected:
kept, dropped = [], []
@@ -1156,6 +1189,7 @@ class OpenAILLM(BaseLLM):
"request the provider would reject",
sorted(missing),
)
self._chain_system_hash = self._system_fingerprint(messages)
return self._to_responses_input(messages, chained=False), None
return input_items, previous_response_id
@@ -1258,6 +1292,18 @@ class OpenAILLM(BaseLLM):
# carryover working whether or not the response is also retained
# server-side (store=true).
params["include"] = ["reasoning.encrypted_content"]
# Backstop against a chain that outgrows the model's native window:
# the provider drops middle items instead of failing every request.
if getattr(settings, "OPENAI_RESPONSES_TRUNCATION_AUTO", False):
params["truncation"] = "auto"
# Prompt-cache hints. The key pins a conversation to one cache shard;
# retention asks for the extended tier where the deployment offers it.
cache_key = getattr(self, "_prompt_cache_key", None)
if cache_key and getattr(settings, "OPENAI_PROMPT_CACHE_KEY", False):
params["prompt_cache_key"] = str(cache_key)
retention = getattr(settings, "OPENAI_PROMPT_CACHE_RETENTION", None)
if retention:
params["prompt_cache_retention"] = retention
return params
@staticmethod
@@ -506,6 +506,21 @@ class ConversationsRepository:
# Shape-gate: see ``set_compression_flags``.
if not looks_like_uuid(conversation_id):
return False
# Idempotent on the same point: two persist paths (the mid-execution
# handler and the route's end-of-stream save) used to both append it.
last_point = self._conn.execute(
text(
"SELECT compression_metadata -> 'compression_points' -> -1 "
"FROM conversations WHERE id = CAST(:id AS uuid)"
),
{"id": conversation_id},
).scalar()
if (
isinstance(last_point, dict)
and last_point.get("query_index") == point.get("query_index")
and last_point.get("compressed_summary") == point.get("compressed_summary")
):
return True
result = self._conn.execute(
text(
"""