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

@@ -338,3 +338,164 @@ class TestIncrementalTailExcludesSummaryRows:
assert result.success and not result.compression_performed
assert result.compressed_summary == "S"
svc.compress_and_save.assert_not_called()
@pytest.mark.unit
class TestAbsolutePersistIndex:
def test_compress_conversation_persists_the_given_absolute_index(self):
llm = MagicMock()
llm.gen.return_value = "<summary>new</summary>"
svc = CompressionService(llm=llm, model_id="m")
svc.prompt_builder = MagicMock(version="v1.0")
svc.prompt_builder.build_prompt.return_value = [{"role": "user", "content": "p"}]
conv = {"queries": [{"prompt": "q18", "response": "r"}, {"prompt": "q19", "response": ""}]}
metadata = svc.compress_conversation(conv, compress_up_to_index=1, persist_query_index=19)
assert metadata.query_index == 19
@patch(
"application.api.answer.services.compression.orchestrator.get_provider_from_model_id",
return_value="openai",
)
@patch(
"application.api.answer.services.compression.orchestrator.get_api_key_for_provider",
return_value="sk",
)
@patch("application.api.answer.services.compression.orchestrator.LLMCreator")
@patch("application.api.answer.services.compression.orchestrator.CompressionService")
@patch("application.api.answer.services.compression.orchestrator.settings")
def test_mid_execution_builds_on_the_carried_summary_and_persists_the_absolute_index(
self, mock_settings, MockCompressionService, MockLLMCreator, _key, _provider,
orchestrator, conversation_service,
):
mock_settings.COMPRESSION_MODEL_OVERRIDE = None
MockLLMCreator.create_llm.return_value = MagicMock()
metadata = MagicMock()
metadata.compression_ratio = 3.0
metadata.original_token_count = 30
metadata.compressed_token_count = 10
metadata.timestamp = "2026-09-03T10:00:00+00:00"
svc = MagicMock()
svc.compress_and_save.return_value = metadata
svc.get_compressed_context.return_value = ("new", [])
MockCompressionService.return_value = svc
conversation_service.get_conversation.return_value = {"queries": [], "compression_metadata": {}}
synthetic = {
"queries": [{"prompt": "q18", "response": "r"}, {"prompt": "q19", "response": ""}],
"compression_metadata": {
"is_compressed": True,
"compression_points": [{"query_index": -1, "compressed_summary": "prior",
"compressed_token_count": 5, "original_token_count": 5}],
},
}
result = orchestrator.compress_mid_execution(
"conv1", "user1", "m", {"sub": "user1"}, current_conversation=synthetic,
persist_query_index=19,
)
assert result.success and result.compression_performed
args, kwargs = svc.compress_and_save.call_args
assert kwargs["start_index"] == 0 # every synthetic query is newer than the summary
assert kwargs["persist_query_index"] == 19 # indexed against the database conversation
@pytest.mark.unit
class TestUnusableSavedPoints:
"""A saved point with an empty summary (older versions wrote them) must
never make a turn drop history."""
def _conversation_with_empty_point(self):
return {
"queries": [
{"prompt": "q0", "response": "first saved turn"},
{"prompt": "q1", "response": "second saved turn"},
],
"compression_metadata": {
"is_compressed": True,
"last_compression_at": EPOCH,
"compression_points": [{
"timestamp": EPOCH, "query_index": 1, "compressed_summary": "",
"original_token_count": 493541, "compressed_token_count": 0,
}],
},
"agent_id": "agent-1",
}
def test_reuse_ignores_an_empty_point_and_keeps_history(
self, orchestrator, conversation_service, threshold_checker
):
conversation_service.get_conversation.return_value = self._conversation_with_empty_point()
threshold_checker.should_compress.return_value = False
result = orchestrator.compress_if_needed("conv1", "user1", "m", {"sub": "user1"})
assert result.success is True
assert result.compressed_summary is None
assert [q["prompt"] for q in result.recent_queries] == ["q0", "q1"]
assert [h["prompt"] for h in result.as_history()] == ["q0", "q1"]
def test_effective_count_ignores_an_empty_point(self):
conv = self._conversation_with_empty_point()
assert TokenCounter.count_effective_conversation_tokens(conv) == (
TokenCounter.count_conversation_tokens(conv)
)
def test_get_compressed_context_ignores_an_empty_point(self):
summary, recent = CompressionService(llm=None, model_id="m").get_compressed_context(
self._conversation_with_empty_point()
)
assert summary is None
assert [q["prompt"] for q in recent] == ["q0", "q1"]
def test_get_compressed_context_falls_back_to_the_latest_usable_point(self):
conv = _compressed_conversation()
conv["queries"].append({"prompt": "q4", "response": "r4"})
conv["compression_metadata"]["compression_points"].append(
{"timestamp": "2026-09-04T09:00:00+00:00", "query_index": 3,
"compressed_summary": " ", "compressed_token_count": 0}
)
summary, recent = CompressionService(llm=None, model_id="m").get_compressed_context(conv)
assert summary == "S"
assert [q["prompt"] for q in recent] == ["q2", "q3", "q4"]
@patch(
"application.api.answer.services.compression.orchestrator.get_provider_from_model_id",
return_value="openai",
)
@patch(
"application.api.answer.services.compression.orchestrator.get_api_key_for_provider",
return_value="sk",
)
@patch("application.api.answer.services.compression.orchestrator.LLMCreator")
@patch("application.api.answer.services.compression.orchestrator.CompressionService")
@patch("application.api.answer.services.compression.orchestrator.settings")
def test_recompression_starts_after_the_latest_usable_point(
self, mock_settings, MockCompressionService, MockLLMCreator, _key, _provider,
orchestrator, conversation_service, threshold_checker,
):
mock_settings.COMPRESSION_MODEL_OVERRIDE = None
conv = _compressed_conversation()
conv["queries"].append({"prompt": "q4", "response": "r4"})
conv["compression_metadata"]["compression_points"].append(
{"timestamp": "2026-09-04T09:00:00+00:00", "query_index": 3,
"compressed_summary": "", "compressed_token_count": 0}
)
conversation_service.get_conversation.return_value = conv
threshold_checker.should_compress.return_value = True
MockLLMCreator.create_llm.return_value = MagicMock()
metadata = MagicMock()
metadata.compression_ratio = 3.0
metadata.original_token_count = 30
metadata.compressed_token_count = 10
metadata.timestamp = "2026-09-05T10:00:00+00:00"
svc = MagicMock()
svc.compress_and_save.return_value = metadata
svc.get_compressed_context.return_value = ("S2", [])
MockCompressionService.return_value = svc
result = orchestrator.compress_if_needed("conv1", "user1", "m", {"sub": "user1"})
assert result.compression_performed
assert svc.compress_and_save.call_args.kwargs["start_index"] == 2
@@ -373,7 +373,7 @@ class TestCompressMidExecution:
"gpt-4",
decoded_token,
user_id="user1",
model_user_id=None,
model_user_id=None, persist_query_index=None,
)
def test_loads_conversation_when_not_provided(
@@ -107,3 +107,112 @@ def test_empty_summary_is_a_failure_not_a_success():
assert success is False and messages is None
conv_service.append_compression_message.assert_not_called()
agent.llm.start_responses_turn.assert_not_called()
# ── the summary already in play must survive a mid-execution compression ────
PRIOR = "Original delivery location: Warehouse Seven."
def _run_with(handler, agent, result, db_conversation, synthetic):
conv_service = Mock()
conv_service.get_conversation.return_value = db_conversation
orchestrator = Mock()
orchestrator.compress_mid_execution.return_value = result
with patch(
"application.api.answer.services.compression.CompressionOrchestrator",
return_value=orchestrator,
), patch(
"application.api.answer.services.conversation_service.ConversationService",
return_value=conv_service,
), patch.object(
handler, "_build_conversation_from_messages", return_value=synthetic
), patch.object(
handler,
"_rebuild_messages_after_compression",
return_value=[{"role": "system", "content": "rebuilt"}],
), patch.object(handler, "_prune_messages_minimal", return_value=None):
outcome = handler._perform_mid_execution_compression(
agent, [{"role": "user", "content": "hi"}]
)
return outcome, orchestrator
@pytest.mark.unit
def test_db_path_carries_the_current_summary_and_persists_the_absolute_index():
agent = _agent()
agent.compressed_summary = PRIOR
agent.last_compression_at = "2026-09-03T09:00:00+00:00"
db_conversation = {
"queries": [{"prompt": f"q{i}", "response": "r"} for i in range(20)],
"compression_metadata": {
"is_compressed": True,
"compression_points": [{"query_index": 17, "compressed_summary": PRIOR,
"compressed_token_count": 12}],
},
}
# What the turn actually replayed: the two turns after the saved point
# plus the in-flight one. Their indexes are 0-2 here, 18-19 in the DB.
synthetic = {"queries": [{"prompt": "q18", "response": "r"}, {"prompt": "q19", "response": ""}],
"compression_metadata": {"is_compressed": False, "compression_points": []}}
(success, _), orchestrator = _run_with(_Handler(), agent, _result(), db_conversation, synthetic)
assert success is True
kwargs = orchestrator.compress_mid_execution.call_args.kwargs
sent = kwargs["current_conversation"]
points = sent["compression_metadata"]["compression_points"]
assert sent["compression_metadata"]["is_compressed"] is True
# The summary the system prompt carries is what the compressor builds on...
assert points[-1]["compressed_summary"] == PRIOR
# ...and it predates every synthetic query, so all of them are "new".
assert points[-1]["query_index"] == -1
# The persisted point must index the DATABASE conversation, not the
# shortened synthetic list.
assert kwargs["persist_query_index"] == 19
@pytest.mark.unit
def test_db_path_without_a_summary_in_play_sends_no_carried_point():
agent = _agent()
agent.compressed_summary = None
db_conversation = {"queries": [{"prompt": "q0", "response": "r"}]}
synthetic = {"queries": [{"prompt": "q0", "response": ""}],
"compression_metadata": {"is_compressed": False, "compression_points": []}}
_, orchestrator = _run_with(_Handler(), agent, _result(), db_conversation, synthetic)
sent = orchestrator.compress_mid_execution.call_args.kwargs["current_conversation"]
assert sent["compression_metadata"]["compression_points"] == []
@pytest.mark.unit
def test_in_memory_path_carries_the_current_summary():
agent = _agent()
agent.compressed_summary = PRIOR
agent.decoded_token = {"sub": "user1"}
metadata = Mock()
metadata.compressed_token_count = 10
metadata.original_token_count = 100
metadata.compression_ratio = 10.0
metadata.timestamp = "2026-09-03T10:00:00+00:00"
metadata.to_dict.return_value = {"query_index": 1, "compressed_summary": "new", "compressed_token_count": 10}
svc = Mock()
svc.compress_conversation.return_value = metadata
svc.get_compressed_context.return_value = ("new", [])
handler = _Handler()
with patch("application.api.answer.services.compression.service.CompressionService", return_value=svc), patch(
"application.llm.llm_creator.LLMCreator"
), patch("application.core.model_utils.get_provider_from_model_id", return_value="openai"), patch(
"application.core.model_utils.get_api_key_for_provider", return_value="sk"
), patch.object(
handler, "_build_conversation_from_messages",
return_value={"queries": [{"prompt": "q", "response": "r"}, {"prompt": "q2", "response": ""}],
"compression_metadata": {"is_compressed": False, "compression_points": []}},
), patch.object(handler, "_rebuild_messages_after_compression", return_value=[{"role": "system", "content": "x"}]):
success, _ = handler._perform_in_memory_compression(agent, [{"role": "user", "content": "hi"}])
assert success is True
sent = svc.compress_conversation.call_args[0][0]
assert sent["compression_metadata"]["compression_points"][-1]["compressed_summary"] == PRIOR
+42
View File
@@ -58,6 +58,11 @@ def _roles(items):
return [i.get("role") for i in items if isinstance(i, dict) and i.get("role")]
def _accepted(llm, rid="resp_1"):
"""The provider accepted the request just built: record its response."""
llm._record_responses_metadata(types.SimpleNamespace(id=rid, output=[], usage=None))
# ── chained system head ──────────────────────────────────────────────────────
@@ -69,6 +74,7 @@ def test_chained_input_omits_unchanged_system_head(monkeypatch):
first, prev = llm._build_responses_input(_messages(), None)
assert prev is None
assert "system" in _roles(first)
_accepted(llm)
chained, prev = llm._build_responses_input(_messages(), "resp_1")
assert prev == "resp_1"
@@ -79,9 +85,11 @@ def test_chained_input_omits_unchanged_system_head(monkeypatch):
def test_chained_input_resends_changed_system_head(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages("sys v1"), None)
_accepted(llm)
chained, _ = llm._build_responses_input(_messages("sys v2"), "resp_1")
assert _roles(chained) == ["system", "user"]
_accepted(llm, "resp_2")
# ...and the new head becomes the one the chain holds.
again, _ = llm._build_responses_input(_messages("sys v2"), "resp_2")
@@ -92,6 +100,7 @@ def test_chained_input_resends_changed_system_head(monkeypatch):
def test_chained_system_head_hash_roundtrips_through_state(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages(), None)
_accepted(llm)
state = llm.export_responses_state()
assert state.get("system_hash")
@@ -105,6 +114,7 @@ def test_chained_system_head_hash_roundtrips_through_state(monkeypatch):
def test_start_responses_turn_forgets_system_head(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages(), None)
_accepted(llm)
llm.start_responses_turn()
chained, _ = llm._build_responses_input(_messages(), "resp_1")
# A fresh chain has no head on the server yet, so the head is sent.
@@ -312,3 +322,35 @@ def test_cache_key_for_user_is_opaque_and_stable():
assert key == _cache_key_for_user("user_2Vhzgd63RSgixvvbF8Z2nhtqnE9")
assert key != _cache_key_for_user("someone-else")
assert _cache_key_for_user(None) is None
# ── the head hash commits only once the provider accepted the request ───────
@pytest.mark.unit
def test_system_hash_commits_only_when_the_response_is_recorded(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages(), None)
# Not recorded (the request failed before the provider stored it): a
# chained follow-up must still carry the head.
chained, _ = llm._build_responses_input(_messages(), "resp_1")
assert _roles(chained) == ["system", "user"]
_accepted(llm)
chained, _ = llm._build_responses_input(_messages(), "resp_1")
assert _roles(chained) == ["user"]
@pytest.mark.unit
def test_failed_chained_request_resends_changed_head_on_retry(monkeypatch):
llm = _make_llm(monkeypatch)
llm._build_responses_input(_messages("sys v1"), None)
_accepted(llm)
first, _ = llm._build_responses_input(_messages("sys v2"), "resp_1")
assert _roles(first) == ["system", "user"]
# Transport error before any chunk: nothing recorded. The same-primary
# retry chains onto resp_1 again, whose stored head is still "sys v1".
retry, _ = llm._build_responses_input(_messages("sys v2"), "resp_1")
assert _roles(retry) == ["system", "user"]
_accepted(llm, "resp_2")
after, _ = llm._build_responses_input(_messages("sys v2"), "resp_2")
assert _roles(after) == ["user"]