Skip to content

Commit d4aba68

Browse files
committed
LCORE-1570: utils/compaction: partition_conversation with degrading guard
Add the partitioning step of conversation compaction. Given an ordered list of conversation items, return (old, recent) where "old" is the chunk that will be summarized and "recent" is the buffer kept verbatim. The buffer is sized in turn pairs (one user message + one assistant message). partition_conversation applies the *degrading guard* of spike decision 9: start at buffer_turns pairs and shrink one pair at a time until the buffer's estimated token count is at or below the available budget. If even a single pair would not fit, the buffer degrades all the way to zero and the entire conversation becomes "old". This handles the pathological case in the spec where a few very large tool-result turns would themselves overflow the context window. Non-message items (function calls, tool results) are carried along with whichever chunk their bracketing messages land in — the partition boundary is always the start of a user/assistant pair, so tool-call control flow is never orphaned mid-turn. The module also exposes the three small duck-type helpers: is_message_item, extract_message_text, format_conversation_for_summary. These are written fresh (not ported from the prior PoC) to keep the production module independent of design-divergent code, but the behavior is shape-compatible with the PoC where the shape work was correct. format_conversation_for_summary will be used by the summarization step in the next commit. The function takes a token estimator dependency by reaching into utils.token_estimator inside the function body to avoid the prospective top-level circular import once LCORE-1572 wires the request-flow integration that has to import both modules. Tests cover the JIRA acceptance criterion (20+ turn conversation yields non-empty old AND recent partitions), the full degrading guard ladder (generous budget keeps 4 pairs; tight budget degrades to 1 pair; impossibly tight budget degrades to 0), edge cases (empty conversation, fewer-than-buffer conversation), the disjoint-and-covering invariant, and the duck-type helpers under both Llama Stack and OpenAI shapes.
1 parent 7587250 commit d4aba68

2 files changed

Lines changed: 470 additions & 0 deletions

File tree

src/utils/compaction.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""Conversation compaction — partitioning, summarization, additive fold-up.
2+
3+
Pure-logic core of the conversation-compaction feature. The module
4+
exposes three units of work:
5+
6+
* ``partition_conversation`` — split an ordered list of conversation
7+
items into the older chunk that will be summarized and the buffer of
8+
recent turns kept verbatim. Applies the *degrading guard*: it starts
9+
with ``buffer_turns`` turn pairs and shrinks the buffer one turn at a
10+
time until the buffer fits inside ``available_budget_tokens``. This
11+
is decision 9 of the spike.
12+
13+
* ``summarize_chunk`` — call the LLM once to summarize a chunk of items
14+
and return a :class:`ConversationSummary`. This is the *additive*
15+
primitive of decision 2: each chunk is summarized once and kept.
16+
Lives in a later commit.
17+
18+
* ``recursively_resummarize`` — fall back to a single-summary collapse
19+
when the cumulative size of existing summaries itself approaches the
20+
context window. Lives in a later commit.
21+
22+
This module deliberately does **not** touch conversation state. It does
23+
not create new Llama Stack conversations, inject marker items, write
24+
to the cache, or acquire locks. Those side-effecting concerns belong
25+
to LCORE-1572 (request-flow integration) and LCORE-1571 (cache
26+
extension). Keeping this layer pure makes it unit-testable without
27+
mocking the whole stack and lets LCORE-1572 wire it in without having
28+
to disentangle a tangle of side effects.
29+
"""
30+
31+
from typing import Any
32+
33+
34+
def is_message_item(item: Any) -> bool:
35+
"""Return True when *item* is a chat-message-shaped conversation item.
36+
37+
Accepts the Llama Stack duck-typed shape (``.type == "message"``)
38+
and the OpenAI-style ``{"role", "content"}`` dict.
39+
40+
Parameters:
41+
item: Conversation item to classify.
42+
43+
Returns:
44+
True when the item is a message; False otherwise (function
45+
calls, tool results, structured outputs, etc.).
46+
"""
47+
if isinstance(item, dict):
48+
return "role" in item
49+
return getattr(item, "type", None) == "message"
50+
51+
52+
def extract_message_text(item: Any) -> str:
53+
"""Return the plain text of a chat-message item.
54+
55+
Accepts both the Llama Stack object shape (``.content`` may be a
56+
string, a list of content-part objects with ``.text``, or a list
57+
of dicts with ``"text"`` keys) and the OpenAI-style dict shape
58+
(``content`` is a string or list of dicts).
59+
60+
Parameters:
61+
item: A chat-message item.
62+
63+
Returns:
64+
The textual content joined by spaces, or the empty string when
65+
no text can be extracted.
66+
"""
67+
content: Any
68+
if isinstance(item, dict):
69+
content = item.get("content")
70+
else:
71+
content = getattr(item, "content", None)
72+
73+
if content is None:
74+
return ""
75+
if isinstance(content, str):
76+
return content
77+
if isinstance(content, list):
78+
parts: list[str] = []
79+
for part in content:
80+
text = None
81+
if hasattr(part, "text"):
82+
text = getattr(part, "text", None)
83+
elif isinstance(part, dict) and "text" in part:
84+
text = part["text"]
85+
if text:
86+
parts.append(text)
87+
return " ".join(parts)
88+
return str(content)
89+
90+
91+
def format_conversation_for_summary(items: list[Any]) -> str:
92+
"""Render conversation items as ``role: text`` lines for a summarization prompt.
93+
94+
Non-message items are skipped — the summarization prompt is meant
95+
for the human-language transcript, not the tool-call control flow.
96+
97+
Parameters:
98+
items: Ordered list of conversation items.
99+
100+
Returns:
101+
Multi-line string. One line per message in the form
102+
``"<role>: <text>"``. Empty when no message items contain text.
103+
"""
104+
lines: list[str] = []
105+
for item in items:
106+
if not is_message_item(item):
107+
continue
108+
if isinstance(item, dict):
109+
role = item.get("role", "unknown")
110+
else:
111+
role = getattr(item, "role", "unknown")
112+
text = extract_message_text(item)
113+
if text:
114+
lines.append(f"{role}: {text}")
115+
return "\n".join(lines)
116+
117+
118+
def _message_indices(items: list[Any]) -> list[int]:
119+
"""Return positions in *items* that correspond to message items."""
120+
return [i for i, item in enumerate(items) if is_message_item(item)]
121+
122+
123+
def partition_conversation(
124+
items: list[Any],
125+
available_budget_tokens: int,
126+
buffer_turns: int,
127+
encoding_name: str,
128+
) -> tuple[list[Any], list[Any]]:
129+
"""Split *items* into ``(old, recent)`` honoring the degrading guard.
130+
131+
"Old" is the chunk that will be summarized; "recent" is the buffer
132+
of trailing turns kept verbatim. The buffer is sized in *turn
133+
pairs* (one user message + one assistant message). The function
134+
starts from ``buffer_turns`` pairs and shrinks one pair at a time
135+
until the recent chunk fits inside ``available_budget_tokens``.
136+
137+
The shrink continues all the way down to zero — at which point all
138+
items are placed in the old chunk and the recent chunk is empty.
139+
This handles the pathological case described in the spec (a few
140+
very large tool-result turns that would themselves overflow the
141+
context window even after summarizing everything else).
142+
143+
Non-message items (function calls, tool results) are kept attached
144+
to whichever chunk their bracketing messages land in. The split
145+
boundary is always the position of a user message — the leading
146+
boundary of a turn pair.
147+
148+
Parameters:
149+
items: Ordered list of conversation items, oldest first.
150+
available_budget_tokens: How many tokens the buffer chunk is
151+
allowed to consume. The compaction runtime computes this
152+
as ``context_window - summary_token_budget - new_query_tokens``.
153+
buffer_turns: Initial buffer size in turn pairs. The degrading
154+
guard reduces this until the buffer fits.
155+
encoding_name: Tiktoken encoding name passed through to the
156+
token estimator so the budget computation matches whatever
157+
encoding the production caller already chose for the
158+
request.
159+
160+
Returns:
161+
``(old_items, recent_items)``. ``old_items`` may be empty (no
162+
compaction needed); ``recent_items`` may be empty (everything
163+
had to be summarized).
164+
"""
165+
# Import locally to avoid a top-level circular import once the
166+
# request-flow integration in LCORE-1572 imports both modules.
167+
from utils.token_estimator import estimate_conversation_tokens
168+
169+
msg_indices = _message_indices(items)
170+
if not msg_indices:
171+
return [], items
172+
173+
# Try each candidate buffer size from buffer_turns down to 0. The
174+
# boundary is always the start of a user/assistant pair: we keep
175+
# the last `n * 2` message items in the buffer.
176+
for candidate_turns in range(buffer_turns, -1, -1):
177+
recent_msg_count = candidate_turns * 2
178+
if recent_msg_count == 0:
179+
# Buffer fully degraded — everything goes to old.
180+
return items, []
181+
if recent_msg_count > len(msg_indices):
182+
# Not enough messages to satisfy this candidate buffer
183+
# size; smaller candidates will be tried next iteration.
184+
continue
185+
split_at = msg_indices[-recent_msg_count]
186+
recent_items = items[split_at:]
187+
recent_tokens = estimate_conversation_tokens(
188+
recent_items, encoding_name=encoding_name
189+
)
190+
if recent_tokens <= available_budget_tokens:
191+
return items[:split_at], recent_items
192+
193+
# Defensive fallback — the loop above always returns. Mirror
194+
# the buffer-fully-degraded branch so the function has a single
195+
# well-defined behavior in every reachable state.
196+
return items, []

0 commit comments

Comments
 (0)