fix(langchain): stop SummarizationMiddleware pulling closed turns back into retention

`_snap_cutoff_past_open_thinking_turn` scanned backward from the
candidate cutoff even when that cutoff already landed on a
`HumanMessage`/`SystemMessage` - a boundary right after a completed
assistant turn, with nothing open to preserve. For a history like
`Human -> AI(thinking+tool) -> Tool -> Human` with the cutoff on the
final human message, the scan walked through the finished turn and,
because it contained a thinking block, incorrectly pulled the cutoff
back to the earlier `AIMessage`, retaining an old turn beyond the
configured retention budget (and undoing the orphan-tool forward
fallback).

The helper now returns the cutoff unchanged whenever
`messages[cutoff_index]` is itself a human/system boundary, before any
backward scanning happens.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Nishitha Mandopen-swe[bot] committed 2026-08-05 18:59:16 +00:00
1 parent 2509af93a9
commit 258917a879
2 files changed
+41

No files matched your search

@@ -844,6 +844,13 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
if cutoff_index <= 0 or cutoff_index >= len(messages):
return cutoff_index
# A cutoff that already lands on a HumanMessage/SystemMessage is itself a
# turn boundary right after the preceding (completed) assistant turn -
# there's nothing open to preserve, so leave it alone rather than scanning
# backward into that already-finished turn.
if isinstance(messages[cutoff_index], (HumanMessage, SystemMessage)):
return cutoff_index
turn_start = cutoff_index
while turn_start > 0:
previous_message = messages[turn_start - 1]
@@ -1017,6 +1017,40 @@ def test_summarization_middleware_find_safe_cutoff_point_no_snap_without_thinkin
assert middleware._find_safe_cutoff_point(messages, 3) == 3
def test_summarization_middleware_find_safe_cutoff_point_closed_turn_not_reopened() -> None:
"""A cutoff already on a Human/System boundary must not be pulled backward.
`Human -> AI(thinking+tool) -> Tool -> Human` with the cutoff on the final
`HumanMessage` is already a safe boundary right after a *completed* assistant
turn. Scanning backward from there (as opposed to refusing to scan at all)
would walk through that finished turn and, because it contains a thinking
block, incorrectly pull the cutoff back to the earlier `AIMessage` - retaining
an old turn beyond the configured retention budget.
"""
model = FakeToolCallingModel()
middleware = SummarizationMiddleware(
model=model, trigger=("messages", 10), keep=("messages", 2)
)
def thinking_block(text: str) -> dict[str, str]:
return {"type": "thinking", "thinking": text, "signature": "sig"}
messages: list[AnyMessage] = [
HumanMessage(content="first request", id="h0"), # index 0
AIMessage(
content=[thinking_block("plan")],
tool_calls=[{"name": "get_data", "args": {}, "id": "call_get"}],
id="a1",
), # index 1
ToolMessage(content="raw data", tool_call_id="call_get", id="t1"), # index 2
HumanMessage(content="second request", id="h1"), # index 3 - naive cutoff, closed turn
]
# The candidate cutoff (index 3) already sits right after the completed turn
# (index 1-2). It must be returned unchanged, not pulled back to index 1.
assert middleware._find_safe_cutoff_point(messages, 3) == 3
def test_summarization_middleware_zero_and_negative_target_tokens() -> None:
"""Test handling of edge cases with target token calculations."""
# Test with very small fraction that rounds to zero