Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fancy-breads-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"langchain": patch
---

avoid invalid message order after summarization
9 changes: 9 additions & 0 deletions libs/langchain/src/agents/middleware/summarization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,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,
Expand Down
56 changes: 55 additions & 1 deletion libs/langchain/src/agents/middleware/tests/summarization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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";

// Mock @langchain/anthropic to test model string usage without requiring the built package
Expand Down Expand Up @@ -379,8 +380,61 @@ describe("summarizationMiddleware", () => {
model,
middleware: [middleware],
});

const result = await agent.invoke({ messages: [] });
expect(result.messages.at(-1)?.content).toBe("Mocked response");
});

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);
});
});
Loading