Skip to content

Commit e27b3f0

Browse files
committed
feat: enhance compactPrefixes to handle interrupted tool calls and improve compaction logic
1 parent a717fbe commit e27b3f0

2 files changed

Lines changed: 84 additions & 2 deletions

File tree

‎packages/core/src/common/compaction.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export function compactPrefixes(
6565
const pending = new Set<number>();
6666
const orphanResults = new Set<number>();
6767
history.forEach((message, index) => {
68+
// Once the conversation resumes, missing earlier results are replayed as interrupted.
69+
if (message.role === "user" || message.role === "assistant") pending.clear();
6870
const params = message.messageParams as { tool_calls?: Array<{ id?: string }>; tool_call_id?: string } | null;
6971
if (message.role === "assistant") {
7072
for (const call of params?.tool_calls ?? []) {
@@ -80,7 +82,7 @@ export function compactPrefixes(
8082
else edges.push([from, index]);
8183
}
8284
});
83-
// An assistant is only closed when every declared call has a following result.
85+
// Without a later conversation message, the trailing batch needs all of its results.
8486
for (const index of pending) {
8587
const params = history[index].messageParams as { tool_calls: Array<{ id?: string }> };
8688
if (

‎packages/core/src/tests/compaction.test.ts‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as path from "node:path";
66
import { createHash } from "node:crypto";
77
import { pathToFileURL } from "node:url";
88
import { SessionManager, type SessionMessage } from "../session";
9+
import { OpenAIMessageConverter } from "../common/openai-message-converter";
910
import { getCompactPrompt, frameCompactSummary } from "../prompt";
1011
import {
1112
compactBudget,
@@ -126,7 +127,11 @@ test("prefixes preserve complete parallel tool groups, include old summaries and
126127
[["summary", "older"], ["summary"]]
127128
);
128129
assert.deepEqual(compactPrefixes([message("huge")], [1000000], 0), []);
129-
assert.deepEqual(compactPrefixes([call, results[0], message("tail")], [10, 10, 10], 0), []);
130+
assert.deepEqual(
131+
compactPrefixes([call, results[0], message("tail")], [10, 10, 10], 0).map((prefix) => prefix.map((m) => m.id)),
132+
[["call", "a"]]
133+
);
134+
assert.deepEqual(compactPrefixes([call, results[0]], [10, 10], 0), []);
130135
const tail = [...history, message("tail")];
131136
assert.equal(
132137
compactPrefixes(
@@ -138,6 +143,81 @@ test("prefixes preserve complete parallel tool groups, include old summaries and
138143
);
139144
});
140145

146+
test("historical interrupted calls can compact, but unfinished trailing tool batches stay intact", () => {
147+
const call = {
148+
...message("call", "assistant"),
149+
messageParams: { tool_calls: [{ id: "missing", type: "function", function: { name: "Bash", arguments: "{}" } }] },
150+
};
151+
const resumed = [call, message("resume", "user", "Continue"), message("work", "assistant"), message("tail")];
152+
const selected = compactPrefixes(resumed, [10, 10, 10, 10], 0)[0];
153+
assert.deepEqual(
154+
selected?.map((m) => m.id),
155+
["call", "resume", "work"]
156+
);
157+
const replay = new OpenAIMessageConverter().buildMessages(selected, false, "test-model");
158+
assert.equal(replay[1].role, "tool");
159+
assert.match(String(replay[1].content), /Previous tool call did not complete/);
160+
assert.deepEqual(compactPrefixes([call], [10], 0), []);
161+
162+
const parallel = { ...call, messageParams: { tool_calls: [{ id: "missing" }, { id: "done" }] } };
163+
const result = { ...message("result", "tool"), messageParams: { tool_call_id: "done" } };
164+
assert.deepEqual(compactPrefixes([parallel, result, message("system", "system")], [10, 10, 10], 0), []);
165+
});
166+
167+
test("compaction follows the converter's preferred result when a real result replaces an interrupted placeholder", () => {
168+
const call = { ...message("call", "assistant"), messageParams: { tool_calls: [{ id: "a" }] } };
169+
const placeholder = {
170+
...message("placeholder", "tool", JSON.stringify({ metadata: { interrupted: true } })),
171+
messageParams: { tool_call_id: "a" },
172+
};
173+
const result = { ...message("result", "tool", "real result"), messageParams: { tool_call_id: "a" } };
174+
const history = [call, placeholder, message("resume"), result, message("tail")];
175+
assert.deepEqual(compactPrefixes(history, [10, 10, 10, 10, 10], 30), []);
176+
const selected = compactPrefixes(history, [10, 10, 10, 10, 10], 0)[0];
177+
assert.deepEqual(
178+
selected.map((m) => m.id),
179+
["call", "placeholder", "resume", "result"]
180+
);
181+
const replay = new OpenAIMessageConverter().buildMessages(selected, false, "test-model");
182+
assert.equal(replay.find((m) => m.role === "tool")?.content, "real result");
183+
});
184+
185+
test("automatic compaction resumes after an interrupted parallel tool batch", async () => {
186+
const call = {
187+
...message("interrupted", "assistant", "old work ".repeat(7000)),
188+
messageParams: {
189+
tool_calls: ["done", "missing"].map((id) => ({
190+
id,
191+
type: "function",
192+
function: { name: "Bash", arguments: "{}" },
193+
})),
194+
},
195+
};
196+
const result = { ...message("result", "tool", "finished result"), messageParams: { tool_call_id: "done" } };
197+
const f = fixture([call, result, message("resume", "user", "continue ".repeat(6000))]);
198+
f.settings.contextWindow = 100000;
199+
const context = await f.internal.prepareContext(f.read(), f.settings.model, true);
200+
f.settings.autoCompactWindow =
201+
f.internal.measureRequest("compact-test", context.messages, f.settings.model).tokens - 10000;
202+
await f.run();
203+
204+
assert.equal(f.requests.length, 2);
205+
assert.equal(f.requests[0].tool_choice, "none");
206+
const toolResults = f.requests[0].messages.filter((m: any) => m.role === "tool");
207+
assert.equal(toolResults.find((m: any) => m.tool_call_id === "done")?.content, "finished result");
208+
assert.match(
209+
toolResults.find((m: any) => m.tool_call_id === "missing")?.content,
210+
/Previous tool call did not complete/
211+
);
212+
assert.equal(f.read().find((m) => m.id === "interrupted")?.compacted, true);
213+
assert.equal(f.read().find((m) => m.id === "result")?.compacted, true);
214+
assert.equal(f.read().find((m) => m.id === "resume")?.compacted, false);
215+
assert.equal(
216+
f.requests[1].messages.some((m: any) => m.role === "tool"),
217+
false
218+
);
219+
});
220+
141221
test("only explicit context and image capacity errors enable recovery", () => {
142222
assert.equal(isContextOverflow({ status: 400, error: { code: "context_length_exceeded" } }), true);
143223
assert.equal(isContextOverflow({ status: 400, message: "maximum context length is 10000 tokens" }), true);

0 commit comments

Comments
 (0)