Skip to content

Commit 302acda

Browse files
authored
fix(langgraph): computeFold falls back to the last human boundary so mid-turn folding works (#66)
The forward-only cut scan meant a long tool loop (protected tail all ai/tool) could never fold: the trigger fired every cycle and computeFold returned null every cycle, exactly when relief was needed. When no human exists at/after the naive cut, the cut now falls back to the LAST HumanMessage before it — keeps more than keepRecent (always safe), and the retained history still opens on a human (nothing orphaned).
1 parent ce76f7e commit 302acda

3 files changed

Lines changed: 64 additions & 0 deletions

File tree

.changeset/mid-turn-fold.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@harpua/langgraph": patch
3+
---
4+
5+
`computeFold` can now fold mid-turn: when the protected tail is all ai/tool messages (a long tool loop) and no HumanMessage exists at/after the naive cut, the cut falls back to the LAST HumanMessage before it — keeping more than `keepRecent` (always safe) with the retained history still opening on a human. Previously the forward-only scan returned null every cycle exactly when relief was needed, so a token trigger fired forever while the whole tool loop rode at peak context.

packages/langgraph/src/__tests__/compaction-cut.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,50 @@ describe("computeFold", () => {
3535
expect(computeFold(msgs, { keepRecent: 1, pin: (m) => (m as any).id === "h1" })).toBeNull();
3636
});
3737
});
38+
39+
describe("mid-turn fold (walkie 012)", () => {
40+
// A long tool loop: the protected tail is ALL ai/tool, so the forward scan
41+
// finds no human and the fold used to return null every cycle — exactly the
42+
// turns that need relief got none. The safe boundary sits BEHIND the
43+
// window: the running turn's own HumanMessage.
44+
function midTurn() {
45+
const msgs: any[] = [
46+
new HumanMessage({ id: "h1", content: "goal" }), // 0 pin
47+
new AIMessage({ id: "a1", content: "ok" }), // 1
48+
new HumanMessage({ id: "h2", content: "old turn" }), // 2
49+
new AIMessage({ id: "a2", content: "done" }), // 3
50+
new HumanMessage({ id: "h3", content: "design the board" }), // 4 running turn
51+
];
52+
for (let i = 0; i < 6; i++) {
53+
msgs.push(new AIMessage({ id: `loop-a${i}`, content: "", tool_calls: [{ name: "t", args: {}, id: `c${i}`, type: "tool_call" }] }));
54+
msgs.push(new ToolMessage({ id: `loop-t${i}`, content: "res", tool_call_id: `c${i}` }));
55+
}
56+
return msgs; // 17 messages, last human at index 4
57+
}
58+
59+
it("falls back to the LAST human at/before the naive cut when the tail is all ai/tool", () => {
60+
// keepRecent=4 => naive cut at 13, no human at/after it. Backward
61+
// fallback cuts at h3 @4: keeps MORE than keepRecent (safe), retained
62+
// history opens on a human, folded span = h1..h3 exclusive.
63+
const plan = computeFold(midTurn(), { keepRecent: 4, pin: (m) => (m as any).id === "h1" });
64+
expect(plan).not.toBeNull();
65+
expect(plan!.foldedSpan.map((m) => (m as any).id)).toEqual(["a1", "h2", "a2"]);
66+
expect(plan!.removeIds).toEqual(["a1", "h2", "a2"]);
67+
});
68+
69+
it("still returns null when the only human at/before the cut is the pinned head's neighbor", () => {
70+
// Nothing foldable between head and the running turn.
71+
const msgs: any[] = [
72+
new HumanMessage({ id: "h1", content: "goal" }),
73+
new HumanMessage({ id: "h2", content: "turn" }),
74+
new AIMessage({ id: "a1", content: "x" }),
75+
new ToolMessage({ id: "t1", content: "r", tool_call_id: "c" }),
76+
];
77+
expect(computeFold(msgs, { keepRecent: 1, pin: (m) => (m as any).id === "h1" })).toBeNull();
78+
});
79+
80+
it("prefers the forward boundary when one exists (between-turn behavior unchanged)", () => {
81+
const plan = computeFold(convo(), { keepRecent: 3, pin: (m) => (m as any).id === "h1" });
82+
expect(plan!.foldedSpan.map((m) => (m as any).id)).toEqual(["a1", "t1", "h2", "a2"]);
83+
});
84+
});

packages/langgraph/src/middleware/compaction-cut.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ export function computeFold(
2727
for (let i = naiveCut; i < n; i++) {
2828
if (isHumanMessage(messages[i]!)) { cut = i; break; }
2929
}
30+
if (cut < 0) {
31+
// Mid-turn: the protected tail is all ai/tool (a long tool loop), so no
32+
// boundary exists at/after the naive cut — but one may sit BEHIND it:
33+
// the running turn's own HumanMessage. Cutting there keeps MORE than
34+
// keepRecent (always safe) and the retained history still opens on a
35+
// human. Without this fallback exactly the turns that need relief get
36+
// none — the trigger fires every cycle and the fold nulls every cycle
37+
// while context rides at peak.
38+
for (let i = naiveCut - 1; i > headIndex + 1; i--) {
39+
if (isHumanMessage(messages[i]!)) { cut = i; break; }
40+
}
41+
}
3042
if (cut < 0 || cut <= headIndex + 1) return null; // nothing to fold safely
3143

3244
const foldedSpan = messages.slice(headIndex + 1, cut);

0 commit comments

Comments
 (0)