Place conversation evidence before the current turn for prompt caching
The summary ledger is regenerated every turn. At the head of history it invalidated the cached retained turns on every request once trimming started. Insert it immediately before the current turn's HumanMessage so system, tools and retained history stay byte-identical; excerpts of dropped turns stay at the head since they only change when the cut moves. Reserve a fixed ledger ceiling (SUMMARY_RESERVE_TOKENS) rather than its current size, so a shrinking ledger cannot pull the cut back and change the retained prefix; fall back to the actual size for tight budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,8 @@ TRUNCATION_NOTE = "\n[truncated: earlier context]"
|
||||
IMAGE_PLACEHOLDER = "[image from an earlier message]"
|
||||
# On overflow, drop old turns down to about this share of the history budget.
|
||||
TRIM_LOW_WATERMARK = 0.65
|
||||
# Upper bound on the summary ledger message (summary.STATE_TOKEN_LIMIT + wrapper).
|
||||
SUMMARY_RESERVE_TOKENS = 2000
|
||||
|
||||
|
||||
def estimate_tokens(value) -> int:
|
||||
@@ -487,23 +489,29 @@ def trim_history(
|
||||
if summary_cost > token_budget:
|
||||
logging.info("Omitting optional conversation summary: insufficient budget")
|
||||
return trimmed
|
||||
try:
|
||||
trimmed, _ = _trim(messages, token_budget, reserve=summary_cost)
|
||||
except ValueError:
|
||||
# Reserve a fixed ceiling, not the ledger's current size, so a shrinking
|
||||
# ledger can't pull the cut back and change the retained prefix.
|
||||
for reserve in dict.fromkeys(
|
||||
(max(summary_cost, SUMMARY_RESERVE_TOKENS), summary_cost)
|
||||
):
|
||||
try:
|
||||
with_room, _ = _trim(messages, token_budget, reserve=reserve)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
# Historical evidence must never evict a current request that fits
|
||||
# without it. Required current-turn metadata retains priority.
|
||||
logging.info(
|
||||
"Omitting optional conversation summary to preserve current request"
|
||||
)
|
||||
return trimmed
|
||||
if (
|
||||
trimmed
|
||||
and isinstance(trimmed[0], AIMessage)
|
||||
and isinstance(trimmed[0].content, str)
|
||||
and trimmed[0].content.startswith("Earlier conversation excerpts")
|
||||
):
|
||||
trimmed = trimmed[1:]
|
||||
return [summary_message, *trimmed]
|
||||
# The ledger changes every turn, so place it just before the current turn:
|
||||
# system, tools and retained history then stay byte-identical for the
|
||||
# prompt cache. Turns start at a HumanMessage, so the last one opens the
|
||||
# current turn and no tool exchange is split.
|
||||
start = max(i for i, m in enumerate(with_room) if isinstance(m, HumanMessage))
|
||||
return [*with_room[:start], summary_message, *with_room[start:]]
|
||||
|
||||
|
||||
def build_context(
|
||||
|
||||
@@ -163,3 +163,17 @@ async def test_deletion_is_scoped_and_survives_reopen(tmp_path):
|
||||
await store.delete("owner", "c")
|
||||
assert (await SummaryStore(path).load("owner", "c")).cursor == 0
|
||||
assert (await SummaryStore(path).load("other", "c")).cursor == 4
|
||||
|
||||
|
||||
async def test_largest_ledger_fits_the_fixed_summary_reserve(tmp_path):
|
||||
from blueprints.conversation.context import SUMMARY_RESERVE_TOKENS, message_tokens
|
||||
|
||||
messages = []
|
||||
for i in range(100):
|
||||
messages += [
|
||||
HumanMessage(content=f"Must {i}: " + "猫😺" * 1000),
|
||||
AIMessage(content=f"Decided {i}: " + "budget " * 1000),
|
||||
]
|
||||
messages.append(HumanMessage(content="current"))
|
||||
state = await SummaryStore(tmp_path / "summary.db").update("u", "c", messages)
|
||||
assert message_tokens(state.as_message()) <= SUMMARY_RESERVE_TOKENS
|
||||
|
||||
@@ -483,13 +483,36 @@ def test_summary_omitted_when_no_turns_dropped():
|
||||
assert summary not in trim_history(messages, summary_message=summary)
|
||||
|
||||
|
||||
def test_summary_prepended_when_turns_dropped():
|
||||
messages = _conversation(12) + [HumanMessage(content="next")]
|
||||
def assert_valid_sequence(out):
|
||||
seen = set()
|
||||
for m in out:
|
||||
if isinstance(m, AIMessage):
|
||||
seen.update(c["id"] for c in m.tool_calls)
|
||||
if isinstance(m, ToolMessage):
|
||||
assert m.tool_call_id in seen
|
||||
|
||||
|
||||
def test_summary_placed_before_current_turn_when_turns_dropped():
|
||||
question = HumanMessage(content="next")
|
||||
# The current turn has its own tool exchange the summary must not split.
|
||||
messages = _conversation(12) + [question, call("now"), result("now")]
|
||||
summary = AIMessage(content="Earlier: keep dining low")
|
||||
out = trim_history(messages, token_budget=6000, summary_message=summary)
|
||||
assert out[0] is summary
|
||||
assert out[-1] is messages[-1]
|
||||
assert cost(out) <= 6000
|
||||
assert out[-4:] == [summary, question, messages[-2], messages[-1]]
|
||||
assert out.count(summary) == 1
|
||||
# Excerpts of dropped turns stay at the head; they only change with the cut.
|
||||
assert out[0].content.startswith("Earlier conversation excerpts")
|
||||
assert_valid_sequence(out)
|
||||
|
||||
|
||||
def test_changing_summary_leaves_retained_prefix_identical():
|
||||
messages = _conversation(12) + [HumanMessage(content="next")]
|
||||
short = AIMessage(content="Earlier: keep dining low")
|
||||
long = AIMessage(content="Earlier: " + "constraint " * 400)
|
||||
a = trim_history(messages, token_budget=6000, summary_message=short)
|
||||
b = trim_history(messages, token_budget=6000, summary_message=long)
|
||||
assert a[: a.index(short)] == b[: b.index(long)]
|
||||
|
||||
|
||||
def test_trim_drops_to_low_watermark_and_prefix_stays_stable():
|
||||
|
||||
Reference in New Issue
Block a user