|
| 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