Mira's converse history is bounded — trim, floor, budget
Until this change the broker handed Mira every message a matter
conversation had ever produced. That tail sits after the prompt's CachePoint,
so it is never cached: the cost of a turn grew linearly with the length of the
session, and — the reason that actually matters — forty old turns
diluted the model's attention on the question counsel just asked. This story
bounds the history broker-side, in _converse_history
(src/clerk_worker/runner.py), which is where it belongs: the brain
receives a finished array and does no windowing of its own.
The rule, in order
1. TRIM each HISTORY message to HISTORY_MESSAGE_MAX_TOKENS (1000)
head 400 tokens + "… N tokens omitted …" + tail 400 tokens
2. NEVER trim the CURRENT counsel message (it is not part of history)
3. FLOOR always keep the newest HISTORY_WINDOW_FLOOR_MESSAGES (10)
4. BUDGET extend backwards in whole exchange pairs, up to
HISTORY_WINDOW_MAX_MESSAGES (25), while the estimated total
stays within HISTORY_WINDOW_MAX_TOKENS (4000)
Why each step is shaped the way it is
| Step | Why |
|---|---|
| Head and tail | The part counsel is asking about sits at one end or the other: a pasted clause is quoted from the top, a long answer is referred to by its conclusion. |
| A visible omission marker | Load-bearing, not cosmetic. Silent truncation hands the model a sentence that simply stops, and a model reads a stopped sentence as a finished one — so it confabulates the ending. On this rail the endings are legal text: a clause cut mid-sentence and silently completed is a fabricated term, spoken in Mira's voice. The count is the real estimated omission, so counsel can decide whether to re-paste. |
| The current message is never trimmed |
Trimming the live question means answering about text counsel was not
shown. It rides command['message'] and never enters the
history array, so it is structurally out of the trim's reach — and a
test over the real command path fails if a refactor ever folds it in.
|
| A floor that beats the budget | Without it, one pasted contract eats the whole token budget and Mira sees a blob and no conversation. Ten messages survive even when they alone exceed 4000 tokens. |
| Whole exchange pairs | An answer whose question was dropped reads as an unprompted assertion, and the model then reasons over a claim with no visible ask behind it. The open edge of the window only ever moves outward, never inward, to land on a question. |
The cross-field validator — the point of the ticket
4000 is 2× memory_fold_min_tokens (2000),
the compaction trigger, and that is not a coincidence to be quietly broken by
a later env change. The fold and this window are two halves of one memory: the
fold holds everything older, the window holds what the fold has not absorbed
yet. If the window were smaller than the trigger, a stretch of conversation
would be held by neither — aged out of the window,
not yet large enough to have been folded — and counsel's reference to it
would go unanswered with no signal anywhere. The 2× (not 1×) covers
lag: _maybe_enqueue_compaction enqueues over SQS, so turns keep
arriving while the compactor runs.
@model_validator(mode="after")
def _window_covers_the_fold_gap(self) -> Settings:
if self.history_window_max_tokens < 2 * self.memory_fold_min_tokens:
raise ValueError(
f"history_window_max_tokens ({self.history_window_max_tokens}) must be >= "
f"2x memory_fold_min_tokens ({self.memory_fold_min_tokens}) — the fold is "
"async; below 2x a reference falls into a gap held by neither."
)
return self
So MEMORY_FOLD_MIN_TOKENS=4000 with the window left at 4000
fails service start, loudly, with the reason attached
— instead of silently dropping context in production.
Config vs constant
| Number | Where it lives |
|---|---|
| 4000 / 25 / 10 / 1000 |
Settings, env-overridable:
HISTORY_WINDOW_MAX_TOKENS,
HISTORY_WINDOW_MAX_MESSAGES,
HISTORY_WINDOW_FLOOR_MESSAGES,
HISTORY_MESSAGE_MAX_TOKENS — placed beside
memory_fold_min_tokens so the coupling is visible at the
point of edit.
|
| head 400 / tail 400 |
Constants. They are the shape of a trim, not a per-deploy knob;
the knob is HISTORY_MESSAGE_MAX_TOKENS, which decides whether
a message is trimmed at all. They are capped at half the configured
per-message budget so lowering that setting still bounds the result.
|
| token estimate | The same char/4 heuristic the fold estimate uses — a budget signal, not billing. It undercounts rather than over, which is the safe direction. |
Observability
When the window drops anything, the broker logs
clerk.converse_history_window_bounded with
dropped, kept, and the estimated token total, so a
session that is losing context shows up as a signal rather than silence
— the same posture as the existing
clerk.converse_history_entries_dropped warning.
tests/unit/clerk_worker/test_converse_history_window.py and
test_runner.py::test_the_current_counsel_message_is_never_trimmed.