From 3ac0647690dbaabb607a9b888d0e0a8d8766d7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Fri, 24 Oct 2025 16:51:59 +0200 Subject: [PATCH 1/3] fix(agents): avoid invalid message order after summarize --- .../src/agents/middleware/summarization.ts | 9 +++ .../middleware/tests/summarization.test.ts | 55 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/libs/langchain/src/agents/middleware/summarization.ts b/libs/langchain/src/agents/middleware/summarization.ts index 56fac21f4c26..61c471621a34 100644 --- a/libs/langchain/src/agents/middleware/summarization.ts +++ b/libs/langchain/src/agents/middleware/summarization.ts @@ -304,6 +304,15 @@ function isSafeCutoffPoint( return true; } + // Prevent preserved messages from starting with AI message containing tool calls + if ( + cutoffIndex < messages.length && + AIMessage.isInstance(messages[cutoffIndex]) && + hasToolCalls(messages[cutoffIndex]) + ) { + return false; + } + const searchStart = Math.max(0, cutoffIndex - SEARCH_RANGE_FOR_TOOL_PAIRS); const searchEnd = Math.min( messages.length, diff --git a/libs/langchain/src/agents/middleware/tests/summarization.test.ts b/libs/langchain/src/agents/middleware/tests/summarization.test.ts index 30d5995e9156..16d1dcc9e67a 100644 --- a/libs/langchain/src/agents/middleware/tests/summarization.test.ts +++ b/libs/langchain/src/agents/middleware/tests/summarization.test.ts @@ -9,6 +9,7 @@ import { import { summarizationMiddleware } from "../summarization.js"; import { countTokensApproximately } from "../utils.js"; import { createAgent } from "../../index.js"; +import { hasToolCalls } from "../../utils.js"; import { FakeToolCallingChatModel } from "../../tests/utils.js"; describe("summarizationMiddleware", () => { @@ -335,4 +336,58 @@ describe("summarizationMiddleware", () => { expect(nonSystemMessages.length).toBeGreaterThanOrEqual(messagesToKeep); expect(nonSystemMessages.length).toBeLessThanOrEqual(messagesToKeep + 3); // Some buffer for safety }); + + it("should not start preserved messages with AI message containing tool calls", async () => { + const summarizationModel = createMockSummarizationModel(); + const model = createMockMainModel(); + + const middleware = summarizationMiddleware({ + model: summarizationModel as any, + maxTokensBeforeSummary: 50, // Very low threshold to trigger summarization + messagesToKeep: 2, // Keep very few messages to force problematic cutoff + }); + + const agent = createAgent({ + model, + middleware: [middleware], + }); + + // Create a conversation history that would cause the problematic scenario + // We need messages where an AI message with tool calls would be the first preserved message + // after summarization if the cutoff isn't adjusted properly + const messages = [ + new HumanMessage(`First message with some content to take up tokens. ${"x".repeat(100)}`), + new AIMessage(`First response. ${"x".repeat(100)}`), + new HumanMessage(`Second message with more content to build up tokens. ${"x".repeat(100)}`), + new AIMessage(`Second response. ${"x".repeat(100)}`), + // This AI message with tool calls should NOT be the first preserved message + new AIMessage({ + content: "Let me search for information.", + tool_calls: [{ id: "call_1", name: "search", args: { query: "test" } }], + }), + new ToolMessage({ + content: "Search results", + tool_call_id: "call_1", + }), + new HumanMessage("What did you find?"), + ]; + + const result = await agent.invoke({ messages }); + + // Verify summarization occurred + expect(result.messages[0]).toBeInstanceOf(SystemMessage); + const systemPrompt = result.messages[0] as SystemMessage; + expect(systemPrompt.content).toContain("## Previous conversation summary:"); + + // Verify preserved messages don't start with AI(tool calls) + const preservedMessages = result.messages.filter( + (m) => !SystemMessage.isInstance(m) + ); + expect(preservedMessages.length).toBeGreaterThan(0); + const firstPreserved = preservedMessages[0]; + // The first preserved message should not be an AI message with tool calls + expect( + !(AIMessage.isInstance(firstPreserved) && hasToolCalls(firstPreserved)) + ).toBe(true); + }); }); From c1aa417be6ac4039058c5e04c737cc3bf526546f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Fri, 24 Oct 2025 17:10:53 +0200 Subject: [PATCH 2/3] chore: add changeset --- .changeset/fancy-breads-allow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fancy-breads-allow.md diff --git a/.changeset/fancy-breads-allow.md b/.changeset/fancy-breads-allow.md new file mode 100644 index 000000000000..f81555e0c215 --- /dev/null +++ b/.changeset/fancy-breads-allow.md @@ -0,0 +1,5 @@ +--- +"langchain": patch +--- + +avoid invalid message order after summarization From 4fd09b041c68c2787d396fbd16124e9ea86a1c81 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Fri, 7 Nov 2025 12:44:30 -0800 Subject: [PATCH 3/3] Update libs/langchain/src/agents/middleware/tests/summarization.test.ts --- .../src/agents/middleware/tests/summarization.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/libs/langchain/src/agents/middleware/tests/summarization.test.ts b/libs/langchain/src/agents/middleware/tests/summarization.test.ts index a7e493e05b07..c5d330154523 100644 --- a/libs/langchain/src/agents/middleware/tests/summarization.test.ts +++ b/libs/langchain/src/agents/middleware/tests/summarization.test.ts @@ -403,9 +403,15 @@ describe("summarizationMiddleware", () => { // We need messages where an AI message with tool calls would be the first preserved message // after summarization if the cutoff isn't adjusted properly const messages = [ - new HumanMessage(`First message with some content to take up tokens. ${"x".repeat(100)}`), + new HumanMessage( + `First message with some content to take up tokens. ${"x".repeat(100)}` + ), new AIMessage(`First response. ${"x".repeat(100)}`), - new HumanMessage(`Second message with more content to build up tokens. ${"x".repeat(100)}`), + new HumanMessage( + `Second message with more content to build up tokens. ${"x".repeat( + 100 + )}` + ), new AIMessage(`Second response. ${"x".repeat(100)}`), // This AI message with tool calls should NOT be the first preserved message new AIMessage({