From 258917a879fc728675e51403203e8b1e4f8bc808 Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:59:16 +0000 Subject: [PATCH] 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] --- .../agents/middleware/summarization.py | 7 ++++ .../implementations/test_summarization.py | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/libs/langchain_v1/langchain/agents/middleware/summarization.py b/libs/langchain_v1/langchain/agents/middleware/summarization.py index 912e269a2c..b773d18057 100644 --- a/libs/langchain_v1/langchain/agents/middleware/summarization.py +++ b/libs/langchain_v1/langchain/agents/middleware/summarization.py @@ -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] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py index 17b629d39a..bfa7c0f8dc 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py @@ -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