fix(context_management): keep tool calls beside a client compaction block clearable

This commit is contained in:
mateo-berri 2026-10-28 19:21:56 -07:00
parent eed5157c63
commit a85a5575f9
3 changed files with 32 additions and 31 deletions

View file

@ -31,7 +31,7 @@ from ..constants import (
)
from ..errors import AnthropicContextManagementError
from ..placeholders import build_cleared_tool_result_content
from .compact import after_client_compaction, count_system_tokens
from .compact import count_system_tokens, history_after_client_compaction
if TYPE_CHECKING:
from litellm.types.llms.openai import ChatCompletionToolParam
@ -242,7 +242,7 @@ def _resolve_keep_count(keep: dict[str, object]) -> int:
def _last_completed_tool_use_id(
messages: list[dict[str, object]],
messages: Sequence[Mapping[str, object]],
) -> str | None:
"""Latest completed tool_result id; never cleared."""
last_id: str | None = None
@ -258,7 +258,7 @@ def _last_completed_tool_use_id(
def _clear_tool_results(
messages: list[dict[str, object]], ids_to_clear: set[str]
messages: list[dict[str, object]], ids_to_clear: Set[str]
) -> tuple[list[dict[str, object]], int]:
"""Clear matching tool_result content; return (messages, cleared_count)."""
cleared = 0
@ -318,10 +318,9 @@ def apply_clear_tool_uses_20250919(
) -> tuple[list[dict[str, object]], AppliedEdit | None]:
"""Apply clear_tool_uses to an edit ``check_clear_tool_uses_spec`` returned; return (messages, AppliedEdit or None).
Like the API, only the history after a client compaction block is edited, with its summary counted as system.
Like the API, it reads only the history after a client compaction block and counts the block's summary as system.
"""
tail_start, counted_system = after_client_compaction(messages, system) or (0, system)
tail: Final = messages[tail_start:]
kept, counted_system = history_after_client_compaction(messages, system)
fields: Final = _ClearToolUsesFields.model_validate(edit_spec)
clear_at_least: Final = 0 if fields.clear_at_least is None else fields.clear_at_least.value
exclude_tools: Final = frozenset(fields.exclude_tools or ())
@ -336,7 +335,7 @@ def apply_clear_tool_uses_20250919(
"value": DEFAULT_KEEP_TOOL_USES,
}
blocks: Final = _all_blocks(tail)
blocks: Final = _all_blocks(kept)
results: Final = _blocks_by_id(blocks, "tool_result", "tool_use_id")
uses: Final = _blocks_by_id(blocks, "tool_use", "id")
tiny: Final = _tiny_result_ids(results)
@ -349,11 +348,11 @@ def apply_clear_tool_uses_20250919(
if len(clearable) <= keep_count:
return messages, None
met, counted_tokens = _trigger_met(trigger, model, tail, tools, counted_system, len(clearable))
met, counted_tokens = _trigger_met(trigger, model, kept, tools, counted_system, len(clearable))
if not met:
return messages, None
last_completed_id: Final = _last_completed_tool_use_id(tail)
last_completed_id: Final = _last_completed_tool_use_id(kept)
candidates: Final = tuple(
(tool_use_id, name)
for tool_use_id, name in clearable[: len(clearable) - keep_count]
@ -368,7 +367,7 @@ def apply_clear_tool_uses_20250919(
_select_in_batches(candidate_ids, tokens, clear_at_least) if clear_at_least > 0 else candidate_ids
)
edited, cleared_count = _clear_tool_results(tail, set(ids_to_clear))
edited, cleared_count = _clear_tool_results(messages, ids_to_clear)
verbose_logger.debug("context_management polyfill: edited: %s", edited)
if cleared_count == 0:
return messages, None
@ -380,15 +379,16 @@ def apply_clear_tool_uses_20250919(
counted_tokens
if counted_tokens is not None
else litellm.token_counter(
model=model, messages=tail, tools=cast("list[ChatCompletionToolParam] | None", tools)
model=model, messages=kept, tools=cast("list[ChatCompletionToolParam] | None", tools)
)
)
kept_after, _ = history_after_client_compaction(final_messages, system)
tokens_after: Final = litellm.token_counter(
model=model, messages=final_messages, tools=cast("list[ChatCompletionToolParam] | None", tools)
model=model, messages=kept_after, tools=cast("list[ChatCompletionToolParam] | None", tools)
)
cleared_input_tokens: Final = max(tokens_before - tokens_after, 0)
return [*messages[:tail_start], *final_messages], {
return final_messages, {
"type": CLEAR_TOOL_USES_EDIT_TYPE,
"cleared_tool_uses": cleared_count,
"cleared_input_tokens": cleared_input_tokens,

View file

@ -167,21 +167,6 @@ def _slice_around_compaction_block(
return sliced_messages, compaction_block
def after_client_compaction(
messages: Sequence[Mapping[str, object]],
system: str | Sequence[Mapping[str, object]] | None,
) -> tuple[int, str | Sequence[Mapping[str, object]] | None] | None:
"""Return where the history after the latest compaction block starts and the system text with its summary."""
sliced, block = _slice_around_compaction_block(messages)
if block is None:
return None
tail_start: Final = len(messages) - len(sliced) + 1
summary: Final = block.get("content")
if not isinstance(summary, str) or not summary:
return tail_start, system
return tail_start, _augment_system_with_summary(_system_to_text(system) or None, summary)
def _strip_compaction_blocks(
messages: Sequence[dict[str, object]],
) -> list[dict[str, object]]:
@ -204,6 +189,21 @@ def _strip_compaction_blocks(
return cleaned
def history_after_client_compaction(
messages: Sequence[dict[str, object]],
system: str | Sequence[Mapping[str, object]] | None,
) -> tuple[list[dict[str, object]], str | Sequence[Mapping[str, object]] | None]:
"""Return the history the API keeps after the latest compaction block, without it, and the system with its summary."""
sliced, block = _slice_around_compaction_block(messages)
if block is None:
return list(messages), system
tail: Final = _strip_compaction_blocks(sliced)
summary: Final = block.get("content")
if not isinstance(summary, str) or not summary:
return tail, system
return tail, _augment_system_with_summary(_system_to_text(system) or None, summary)
def _augment_system_with_summary(
system: str | list[dict[str, object]] | None,
summary_text: str,

View file

@ -726,14 +726,15 @@ def test_input_tokens_trigger_counts_the_system_prompt(system: str | list[dict[s
@pytest.mark.parametrize("compact_position", [None, "after", "before"], ids=["alone", "before_compact", "after_compact"])
async def test_input_tokens_trigger_counts_a_client_compaction_summary(compact_position: str | None) -> None:
"""A client compaction summary moves into the system prompt, so the trigger counts it too, in any edit order."""
"""A client compaction summary counts toward the trigger and calls beside it stay clearable, in any edit order."""
tail: Final = _make_history(n_pairs=3)
edit_spec: Final = {
"type": "clear_tool_uses_20250919",
"trigger": {"type": "input_tokens", "value": litellm.token_counter(model=MODEL, messages=tail) + 100},
"keep": {"type": "tool_uses", "value": 1},
}
compacted_turn: Final = {"role": "assistant", "content": [{"type": "compaction", "content": "Earlier work. " * 400}]}
compaction: Final = {"type": "compaction", "content": "Earlier work. " * 400}
compacted_history: Final = [tail[0], {**tail[1], "content": [compaction, *tail[1]["content"]]}, *tail[2:]]
compact_spec: Final = {"type": "compact_20260112", "trigger": {"type": "input_tokens", "value": 50_000}}
edits: Final = {None: [edit_spec], "after": [edit_spec, compact_spec], "before": [compact_spec, edit_spec]}[
compact_position
@ -756,7 +757,7 @@ async def test_input_tokens_trigger_counts_a_client_compaction_summary(compact_p
return [edit.get("cleared_tool_uses") for edit in applied if edit.get("type") == "clear_tool_uses_20250919"]
without_summary: Final = await prepare(deepcopy(tail))
with_summary: Final = await prepare([compacted_turn, *deepcopy(tail)])
with_summary: Final = await prepare(deepcopy(compacted_history))
assert cleared(without_summary) == []
assert cleared(with_summary) == [2]