Coverage for src/lilbee/retrieval/query/history_window.py: 100%
24 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Token-budget history windowing for chat conversations."""
3from __future__ import annotations
5from collections.abc import Callable
6from typing import TYPE_CHECKING
8from lilbee.data.extract.chunk import CHARS_PER_TOKEN
10if TYPE_CHECKING:
11 from lilbee.retrieval.query.searcher import ChatMessage
14def estimate_text_tokens(text: str) -> int:
15 """Cheap char/4 token estimate for a string."""
16 return max(1, len(text) // CHARS_PER_TOKEN)
19def chars_for_tokens(tokens: int) -> int:
20 """Rough char budget for a token budget: the inverse of estimate_text_tokens.
22 The chars-per-token ratio itself is owned by :mod:`lilbee.data.extract.chunk`, the
23 one place it is defined; this is the token->char direction of it.
24 """
25 return tokens * CHARS_PER_TOKEN
28def estimate_tokens(message: ChatMessage) -> int:
29 """Cheap char/4 token estimate for one message."""
30 return estimate_text_tokens(message["content"])
33def windowed_history(
34 messages: list[ChatMessage],
35 *,
36 max_tokens: int,
37 estimator: Callable[[ChatMessage], int] = estimate_tokens,
38) -> list[ChatMessage]:
39 """Return the suffix of *messages* whose token cost fits in *max_tokens*.
41 Drops messages from the front in pairs so the window starts at a user
42 message; never strands an orphan assistant reply with no preceding user
43 turn for the model to anchor to. The newest pair is always kept even
44 if it exceeds the budget on its own (caller decides what to do then).
46 A non-positive *max_tokens* disables windowing and returns everything,
47 rather than windowing hardest. No production caller can reach it today
48 (the context target has a floor), but a caller deriving a budget that
49 goes non-positive gets the full history, not an empty one.
50 """
51 if max_tokens <= 0 or not messages:
52 return list(messages)
53 sizes = [estimator(m) for m in messages]
54 total = sum(sizes)
55 if total <= max_tokens:
56 return list(messages)
57 start = 0
58 # ``len(messages) - 2`` keeps the newest user/assistant pair even when it
59 # exceeds the budget on its own. The caller decides what to do if the
60 # final pair is over-sized (typically: send it anyway and let the chat
61 # server error if it must, rather than send nothing at all).
62 while start < len(messages) - 2 and total > max_tokens:
63 # Drop the front pair (user + assistant). If the front isn't a user
64 # message (malformed input), drop one to realign.
65 drop = 2 if messages[start]["role"] == "user" else 1
66 for i in range(start, min(start + drop, len(messages))):
67 total -= sizes[i]
68 start += drop
69 return list(messages[start:])