Repository navigation
Conversation
Extract _last_usage_thunk, which returns the latest ModelOutputThunk with usage, and _usage_total, which computes the total from a usage dict. _last_usage_tokens now uses both. No behaviour change. Signed-off-by: Akshat Saxena <akshatsaxena977@gmail.com>
26247c3 to
4eebc24
Compare
_as_list_token_budget estimated every item at len(rendered) // 4. When the latest reply carries usage from the context's model, compare that measured count with the estimate for the items the reply was generated from (the recorded context, the action and the reply itself), and multiply every item's estimate by that ratio before trimming. The ratio is only used when the reply's model matches the context's model and the items before the reply are the same objects the backend recorded as sent. Otherwise the estimates are used unchanged, as before. Items are rendered only when their estimate is needed, so a long history is not rendered past what the budget can hold. Refs generative-computing#1319 Signed-off-by: Akshat Saxena <akshatsaxena977@gmail.com>
4eebc24 to
24e3e6e
Compare
|
@jakelorocco this is ready for review whenever you have time. Checks are passing, and I'm happy to change anything. |
markstur
left a comment
There was a problem hiding this comment.
The idea of correcting the len // 4 estimate with measured usage is sound and the fallback handling for mismatched receipts is careful. Comments are correctness issues that would affect ordinary use, since a first turn and any tool-using conversation hits them. The main concerns are about guarding the extreme against extreme scaling which would likely be a problem.
AI-generated review comments and I cross-checked them. Feel free to call out mistakes or ask for more clarity.
| if receipt is None: | ||
| return 1.0 | ||
|
|
||
| idx = next(i for i, c in enumerate(items) if c is thunk) |
There was a problem hiding this comment.
Unguarded next() (chat.py:285) — idx = next(...) with no default crashes view_for_generation with StopIteration if the items argument ever diverges from self.as_list(), instead of degrading to scale 1.0 like every other mismatch.
The unguarded next() can't fail today, but _usage_scale only works because of an assumption its signature doesn't state.
Why it's safe now
Only caller: _as_list_token_budget is the only caller. It builds items = self.as_list() and passes it in.
Where the thunk comes from: thunk comes from _last_usage_thunk(self), which iterates ctx.as_list() on the same context. The thunk is therefore always an element of items.
Contexts that can't be linearized: If as_list() couldn't linearize a context, it would fail earlier, before this line.
Why it's still fragile
_usage_scale takes items as a parameter, so its contract says nothing about items and self having to agree. Called directly with an items list that lacks the thunk, and it raised StopIteration. A bare StopIteration is also a poor failure: a future caller inside a generator or coroutine would get a confusing error instead of the fallback.
Everything else in the method returns 1.0 when the data doesn't line up. This line is the only one that raises, which is inconsistent with that pattern.
| """ | ||
| from mellea.stdlib.context.compactor import _last_usage_thunk, _usage_total | ||
|
|
||
| thunk = _last_usage_thunk(self) |
There was a problem hiding this comment.
Redundant O(n) work (chat.py:274) — _usage_scale triggers a second full as_list() walk plus a third identity scan per call, and re-checks usage is None that _last_usage_thunk already guarantees. view_for_generation runs 2–3× per generation on hot paths.
I haven't looked into how to refactor this. Author should at least consider it.
|
|
||
| return self.as_list() | ||
|
|
||
| def _usage_scale(self, items: list[Span], estimate: Callable[[int], int]) -> float: |
There was a problem hiding this comment.
the scale blows up when the measured segment is small.
On the first turn the receipt is empty, so estimated covers only the action and the reply, a handful of tokens. measured, though, includes the fixed template and system-prompt overhead, which can be tens of tokens. The ratio is then very large. For example, a 3-token question and a 2-token reply with a measured total of 40 give a scale of about 8. Every history item is then costed at that multiple until a larger reply replaces the measurement, so the context is trimmed far more than needed. This is the normal case on turn one, not a corner case.
Please guard against small denominators, for example:
skip the correction (return 1.0) when estimated is below a minimum, such as a fraction of the budget or a fixed token count, and/or
clamp the result to a sane range such as 0.5–2.0.
Please also add a test for a first-turn reply (empty receipt, small action and reply, measured usage dominated by overhead). The current tests all use 100-character items, so they don't reach this case.
| if i not in estimates: | ||
| item = items[i] | ||
| rendered = formatter.print(item) if formatter is not None else str(item) | ||
| estimates[i] = max(1, len(rendered) // 4) |
There was a problem hiding this comment.
tool-call and reasoning thunks are costed at the 1-token floor.
A ModelOutputThunk with an empty value, or one carrying only tool calls, renders as ''. Its estimate is therefore 1 token, whatever it really costs. This causes two problems:
If such a thunk is inside the measured segment, estimated is too low and the scale is inflated, so history is over-trimmed.
If it is added after the reply, it costs about scale × 1 tokens when it may cost hundreds. The context is then over-filled, which is the failure this budget is meant to prevent.
Please either estimate these thunks from their tool calls and reasoning text, or skip the correction when the measured segment contains one. Please add a test with a tool-call thunk both inside the measured segment and after the reply.
| estimated = sum(estimate(i) for i in range(start, idx + 1)) | ||
| if measured is None or estimated <= 0: | ||
| return 1.0 | ||
| return measured / estimated |
There was a problem hiding this comment.
Non-blocking, but needs a decision: overhead is counted twice.
measured is the full request cost, including the system prompt, tool schemas and template tokens, none of which are in items. Dividing by the item-only estimate folds that overhead into every item's cost. The 0.75 headroom factor already reserves capacity for the same overhead, so history can be trimmed when the real request would have fit.
This is the same cause as comment 1 and the clamp or minimum there reduces it. Please either subtract a known overhead or switch to prompt_tokens. If you keep the current approach, update the docstrings on _usage_scale and _as_list_token_budget to say that overhead is included in the scale. Then the 0.75 headroom can be revisited deliberately rather than stacked by accident.
Address review feedback on the measured-usage correction: - Find the measured reply with one backwards pass over the items passed in, instead of walking the context again and searching for the reply with next(). A list without the reply now falls back to no correction instead of raising StopIteration. - Skip the correction when the measured items are estimated at fewer than 100 tokens, where fixed overhead (system prompt, chat template) dominates, as on a first turn. Clamp the ratio to 0.5-2.0. - Estimate replies from their tool calls and reasoning text as well as their value, so a tool-call-only reply is no longer costed at 1 token. - Document that the scale includes a share of fixed overhead, which overlaps with the 0.75 headroom. Refs generative-computing#1319 Signed-off-by: Akshat Saxena <akshatsaxena977@gmail.com>
|
Thanks @markstur for the detailed review. All five points are addressed in the latest commit:
The new tests fail on the previous commit and pass now. |
ajbozarth
left a comment
There was a problem hiding this comment.
Some feedback from Claude: verified end-to-end against Ollama (granite4.1:3b, 3 turns via both chat and instruct). The receipt and model checks pass on every turn and the scale is steady at ~0.82, so the correction is active on the default path. I also confirmed Ollama's prompt_eval_count still counts KV-cached prefix tokens, so cached turns don't skew the ratio. One nit inline.
_usage_scale finds the measured reply with its own loop, so _last_usage_thunk had a single caller. Fold it back into _last_usage_tokens, keeping _usage_total as the shared helper. Refs generative-computing#1319 Signed-off-by: Akshat Saxena <akshatsaxena977@gmail.com>
|
Thanks @ajbozarth for checking it end-to-end against Ollama! Went with inlining: _last_usage_thunk is folded back into _last_usage_tokens, and _usage_total stays as the shared helper. Pushed. |
Pull Request
Issue
Fixes #1319
Description
ChatContext._as_list_token_budgetdecides which history items fit the model's context window. It estimates each item atlen(rendered) // 4. That guess can be well off, because tokenizers differ by model and chat templates add tokens of their own.Backends already record real token usage on each reply (
generation.usage), and they record what was sent on_call(contextandaction). This PR uses that record to correct the estimate:generation.modelmatches the context's model. With aModelIdentifier, any of its platform names counts as a match._call.action._call.context(what the backend sent).If any check fails, the ratio is 1.0 and behaviour is exactly as before.
Other changes:
_last_usage_tokensincompactor.pyis split into_last_usage_thunkand_usage_totalso both can be reused. Its behaviour is unchanged.Decisions worth a look
ChatContextknows its model but not its backend, so the check compares the model name only. If the context has no model (onlytoken_context_length_limit), the correction is skipped.Testing
New tests in
test/stdlib/test_base_context.py:_model_matcheswith string andModelIdentifiermodels.The new trimming tests fail on
mainand pass with this change. The existing context, compactor, stdlib, core and backend unit tests give the same results before and after (run with--disable-default-mellea-plugins). I haven't checked this against a live backend.Attribution
Adding a new component, requirement, sampling strategy, or tool?
If your PR adds or modifies one of the types below, check the matching box. A checklist of type-specific review items will be posted as a comment.