Skip to content

Commit 2ae297f

Browse files
committed
LCORE-1570: utils/compaction: recursive resummarize fallback
Add recursively_resummarize — the spec's escape hatch when additive compaction's per-chunk summaries themselves grow large enough to threaten the context window. Without it, the additive design grows its summary set unboundedly across a long-running conversation; the fold collapses every chunk into a single ConversationSummary. The function takes a list of existing summaries (oldest first) and calls the LLM once with a prompt that lists each chunk in order and asks for a combined summary preserving the same five Red Hat support directives. The new summary inherits summarized_through_turn from the most recent input — no new conversation turns were summarized by this call; the running total has not advanced. Folding a single summary is a no-op and raises ValueError so a caller that only has one chunk does not silently make an unnecessary LLM call. Same for an empty list. A separate RECURSIVE_RESUMMARIZATION_PROMPT constant keeps the fold's prompt distinct from the additive prompt (the input is qualitatively different — already-summarized text rather than raw transcript — though the preservation directives are the same). The function intentionally does not decide *when* to fold. The caller (LCORE-1572) compares the cumulative summary token count against some configured fraction of the context window and invokes this only at the threshold. Tests cover: * Multi-summary fold returns one ConversationSummary inheriting the most recent through-turn count. * Prompt body contains every input summary's text and the fallback prompt constant. * store=False / stream=False on the LLM call. * Single-summary and empty-list inputs raise ValueError. * Empty LLM response raises ValueError (mirrors summarize_chunk).
1 parent a8ab74e commit 2ae297f

2 files changed

Lines changed: 253 additions & 0 deletions

File tree

src/utils/compaction.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,3 +354,111 @@ async def summarize_chunk(
354354
created_at=datetime.now(timezone.utc).isoformat(),
355355
model_used=model,
356356
)
357+
358+
359+
RECURSIVE_RESUMMARIZATION_PROMPT = (
360+
"The following are summaries of older portions of a conversation"
361+
" between a user and an AI assistant helping with Red Hat product"
362+
" support. Combine them into a single summary that preserves the"
363+
" same five categories of detail (original question and"
364+
" environment, error messages and outcomes, decisions and"
365+
" rationale, what was resolved versus what is open, and clear"
366+
" attribution between user and assistant). Resolve repetition;"
367+
" keep all distinct facts.\n"
368+
)
369+
"""Prompt used when collapsing multiple summaries into one.
370+
371+
Separate from SUMMARIZATION_PROMPT because the input is qualitatively
372+
different (already-summarized text, not raw conversation), but the
373+
preservation directives are the same.
374+
"""
375+
376+
377+
async def recursively_resummarize(
378+
client: AsyncLlamaStackClient,
379+
model: str,
380+
summaries: list[ConversationSummary],
381+
encoding_name: str,
382+
) -> ConversationSummary:
383+
"""Collapse multiple ``ConversationSummary`` records into one.
384+
385+
This is the fallback for the additive design (spike decision 2).
386+
Each compaction normally produces a new summary chunk that is kept
387+
alongside the previous ones, but the spec mandates a recursive
388+
fold-up when the cumulative size of the summaries themselves
389+
approaches the context limit:
390+
391+
When total summary token count itself approaches the context
392+
limit, fall back to recursive re-summarization of the oldest
393+
summary chunks.
394+
— docs/design/conversation-compaction/conversation-compaction.md
395+
396+
The caller decides *when* to invoke this (typically after measuring
397+
that the total summary tokens cross some configured fraction of
398+
the model's context window). This function carries out the fold:
399+
it builds a prompt that lists each existing summary in order, asks
400+
the LLM to produce a single combined summary preserving the same
401+
five directives, and returns a fresh ``ConversationSummary``.
402+
403+
The returned summary inherits ``summarized_through_turn`` from the
404+
most recent input summary (the running total has not advanced —
405+
we have re-folded, not summarized anything new).
406+
407+
Parameters:
408+
client: Llama Stack client to call.
409+
model: Fully-qualified model identifier used for the LLM call.
410+
summaries: Existing summary chunks to fold, in chronological
411+
order (oldest first). Must contain at least two entries —
412+
folding a single summary is a no-op the caller should
413+
short-circuit before invoking this function.
414+
encoding_name: Tiktoken encoding name used to count tokens in
415+
the produced fold.
416+
417+
Returns:
418+
A single ConversationSummary representing the union of
419+
``summaries``.
420+
421+
Raises:
422+
ValueError: When *summaries* has fewer than two entries (no
423+
fold is needed) or when the LLM call yields no
424+
extractable text.
425+
"""
426+
from utils.token_estimator import estimate_tokens
427+
428+
if len(summaries) < 2:
429+
raise ValueError(
430+
"recursively_resummarize requires at least 2 summary chunks "
431+
"to fold; caller must short-circuit when fewer are present."
432+
)
433+
434+
transcript = "\n\n".join(
435+
f"Summary {i + 1} (through turn {s.summarized_through_turn}):\n"
436+
f"{s.summary_text}"
437+
for i, s in enumerate(summaries)
438+
)
439+
prompt = f"{RECURSIVE_RESUMMARIZATION_PROMPT}\n{transcript}"
440+
441+
logger.info(
442+
"Recursively re-summarizing %d existing summary chunks for model %s.",
443+
len(summaries),
444+
model,
445+
)
446+
response = await client.responses.create(
447+
input=prompt,
448+
model=model,
449+
stream=False,
450+
store=False,
451+
)
452+
folded_text = _extract_response_text(response).strip()
453+
if not folded_text:
454+
raise ValueError(
455+
"Recursive re-summarization LLM call returned no extractable "
456+
"text; cannot fold the existing ConversationSummary records."
457+
)
458+
return ConversationSummary(
459+
summary_text=folded_text,
460+
summarized_through_turn=summaries[-1].summarized_through_turn,
461+
token_count=estimate_tokens(folded_text, encoding_name=encoding_name),
462+
created_at=datetime.now(timezone.utc).isoformat(),
463+
model_used=model,
464+
)

tests/unit/utils/test_compaction.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
import pytest
66
from pytest_mock import MockerFixture
77

8+
from models.compaction import ConversationSummary
89
from utils.compaction import (
10+
RECURSIVE_RESUMMARIZATION_PROMPT,
911
SUMMARIZATION_PROMPT,
1012
_build_summarization_prompt_body,
1113
_extract_response_text,
1214
extract_message_text,
1315
format_conversation_for_summary,
1416
is_message_item,
1517
partition_conversation,
18+
recursively_resummarize,
1619
summarize_chunk,
1720
)
1821
from utils.token_estimator import (
@@ -499,3 +502,145 @@ async def test_additive_second_call_does_not_resummarize_first(
499502
assert first.summary_text == "First summary."
500503
assert second.summary_text == "Second summary."
501504
assert second.summarized_through_turn > first.summarized_through_turn
505+
506+
507+
# ---------------------------------------------------------------------------
508+
# recursively_resummarize
509+
# ---------------------------------------------------------------------------
510+
511+
512+
def _make_summary(text: str, through: int, tokens: int = 5) -> ConversationSummary:
513+
"""Build a ConversationSummary value for tests."""
514+
return ConversationSummary(
515+
summary_text=text,
516+
summarized_through_turn=through,
517+
token_count=tokens,
518+
created_at="2026-05-11T00:00:00Z",
519+
model_used="openai/gpt-4o-mini",
520+
)
521+
522+
523+
class TestRecursivelyResummarize:
524+
"""Tests for the recursive-fold fallback."""
525+
526+
@pytest.mark.asyncio
527+
async def test_collapses_n_summaries_into_one(
528+
self, mocker: MockerFixture
529+
) -> None:
530+
"""Multiple summaries fold into one ConversationSummary."""
531+
client = mocker.AsyncMock()
532+
client.responses.create.return_value = _make_summary_response(
533+
mocker, "Combined summary covering all prior chunks."
534+
)
535+
summaries = [
536+
_make_summary("First chunk summary.", through=5),
537+
_make_summary("Second chunk summary.", through=10),
538+
_make_summary("Third chunk summary.", through=15),
539+
]
540+
folded = await recursively_resummarize(
541+
client=client,
542+
model="openai/gpt-4o-mini",
543+
summaries=summaries,
544+
encoding_name=DEFAULT_ENCODING_NAME,
545+
)
546+
assert (
547+
folded.summary_text
548+
== "Combined summary covering all prior chunks."
549+
)
550+
# The fold inherits the most recent input's running total — no
551+
# new turns were summarized by this call.
552+
assert folded.summarized_through_turn == 15
553+
assert folded.token_count > 0
554+
assert folded.model_used == "openai/gpt-4o-mini"
555+
556+
@pytest.mark.asyncio
557+
async def test_prompt_lists_each_summary(
558+
self, mocker: MockerFixture
559+
) -> None:
560+
"""All input summary texts and the fallback prompt appear in the call."""
561+
client = mocker.AsyncMock()
562+
client.responses.create.return_value = _make_summary_response(
563+
mocker, "Folded summary."
564+
)
565+
summaries = [
566+
_make_summary("Alpha facts.", through=5),
567+
_make_summary("Beta facts.", through=10),
568+
]
569+
await recursively_resummarize(
570+
client=client,
571+
model="openai/gpt-4o-mini",
572+
summaries=summaries,
573+
encoding_name=DEFAULT_ENCODING_NAME,
574+
)
575+
prompt_input = client.responses.create.call_args.kwargs["input"]
576+
assert RECURSIVE_RESUMMARIZATION_PROMPT in prompt_input
577+
assert "Alpha facts." in prompt_input
578+
assert "Beta facts." in prompt_input
579+
assert "Summary 1" in prompt_input
580+
assert "Summary 2" in prompt_input
581+
582+
@pytest.mark.asyncio
583+
async def test_uses_store_false(self, mocker: MockerFixture) -> None:
584+
"""The recursive call also uses store=False — like the additive one."""
585+
client = mocker.AsyncMock()
586+
client.responses.create.return_value = _make_summary_response(
587+
mocker, "Folded."
588+
)
589+
summaries = [
590+
_make_summary("a", through=1),
591+
_make_summary("b", through=2),
592+
]
593+
await recursively_resummarize(
594+
client=client,
595+
model="openai/gpt-4o-mini",
596+
summaries=summaries,
597+
encoding_name=DEFAULT_ENCODING_NAME,
598+
)
599+
kwargs = client.responses.create.call_args.kwargs
600+
assert kwargs["store"] is False
601+
assert kwargs["stream"] is False
602+
603+
@pytest.mark.asyncio
604+
async def test_raises_for_single_summary(
605+
self, mocker: MockerFixture
606+
) -> None:
607+
"""Folding a single summary is a no-op the caller must avoid."""
608+
client = mocker.AsyncMock()
609+
with pytest.raises(ValueError, match="at least 2 summary chunks"):
610+
await recursively_resummarize(
611+
client=client,
612+
model="openai/gpt-4o-mini",
613+
summaries=[_make_summary("only one", through=5)],
614+
encoding_name=DEFAULT_ENCODING_NAME,
615+
)
616+
617+
@pytest.mark.asyncio
618+
async def test_raises_for_empty_list(self, mocker: MockerFixture) -> None:
619+
"""Folding an empty list is a no-op the caller must avoid."""
620+
client = mocker.AsyncMock()
621+
with pytest.raises(ValueError, match="at least 2 summary chunks"):
622+
await recursively_resummarize(
623+
client=client,
624+
model="openai/gpt-4o-mini",
625+
summaries=[],
626+
encoding_name=DEFAULT_ENCODING_NAME,
627+
)
628+
629+
@pytest.mark.asyncio
630+
async def test_raises_when_llm_returns_empty(
631+
self, mocker: MockerFixture
632+
) -> None:
633+
"""Empty LLM response surfaces ValueError, not an empty fold."""
634+
client = mocker.AsyncMock()
635+
client.responses.create.return_value = _make_summary_response(mocker, "")
636+
summaries = [
637+
_make_summary("a", through=1),
638+
_make_summary("b", through=2),
639+
]
640+
with pytest.raises(ValueError, match="no extractable text"):
641+
await recursively_resummarize(
642+
client=client,
643+
model="openai/gpt-4o-mini",
644+
summaries=summaries,
645+
encoding_name=DEFAULT_ENCODING_NAME,
646+
)

0 commit comments

Comments
 (0)