Skip to content

Commit e664c42

Browse files
committed
compaction fail safe
1 parent 9592e54 commit e664c42

File tree

4 files changed

+191
-15
lines changed

4 files changed

+191
-15
lines changed

packages/scout-agent/lib/compaction.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import {
88
COMPACT_CONVERSATION_TOOL_NAME,
99
COMPACTION_MARKER_TOOL_NAME,
1010
countCompactionMarkers,
11+
maxConsecutiveCompactionAttempts,
1112
createCompactionMarkerPart,
1213
createCompactionTool,
1314
findAPICallError,
1415
findCompactionSummary,
1516
isOutOfContextError,
17+
MAX_CONSECUTIVE_COMPACTION_ATTEMPTS,
1618
} from "./compaction";
1719
import type { Message } from "./types";
1820

@@ -302,6 +304,41 @@ describe("countCompactionMarkers", () => {
302304
});
303305
});
304306

307+
describe("maxConsecutiveCompactionAttempts", () => {
308+
test("counts consecutive assistant compaction attempts", () => {
309+
const messages: Message[] = [
310+
userMsg("1", "Hello"),
311+
summaryMsg("summary-1", "Summary output 1"),
312+
summaryMsg("summary-2", "Summary output 2"),
313+
];
314+
315+
expect(maxConsecutiveCompactionAttempts(messages)).toBe(2);
316+
});
317+
318+
test("ignores summary acknowledgment when counting", () => {
319+
const messages: Message[] = [
320+
summaryMsg("summary-1", "First summary"),
321+
{
322+
id: "compaction-summary-response",
323+
role: "assistant",
324+
parts: [{ type: "text", text: "Acknowledged." }],
325+
},
326+
summaryMsg("summary-2", "Second summary"),
327+
];
328+
329+
expect(maxConsecutiveCompactionAttempts(messages)).toBe(1);
330+
});
331+
332+
test("stops at non-compaction assistant message", () => {
333+
const messages: Message[] = [
334+
markerMsg("marker1"),
335+
assistantMsg("assistant", "Normal reply"),
336+
];
337+
338+
expect(maxConsecutiveCompactionAttempts(messages)).toBe(0);
339+
});
340+
});
341+
305342
describe("buildCompactionRequestMessage", () => {
306343
test("creates user message with correct role", () => {
307344
const message = buildCompactionRequestMessage();
@@ -328,6 +365,20 @@ describe("applyCompactionToMessages", () => {
328365
expect(result).toEqual(messages);
329366
});
330367

368+
test("throws when consecutive compaction attempts hit the limit", () => {
369+
const attempts = MAX_CONSECUTIVE_COMPACTION_ATTEMPTS + 1;
370+
const messages: Message[] = [
371+
userMsg("1", "Hello"),
372+
...Array.from({ length: attempts }, (_, idx) =>
373+
summaryMsg(`summary-${idx}`, `Summary ${idx}`)
374+
),
375+
];
376+
377+
expect(() => applyCompactionToMessages(messages)).toThrow(
378+
/Compaction loop detected/
379+
);
380+
});
381+
331382
test("excludes correct number of messages based on marker count", () => {
332383
const messages: Message[] = [
333384
userMsg("1", "Message 1"),
@@ -599,6 +650,10 @@ describe("applyCompactionToMessages", () => {
599650
userMsg("3", "Third message"),
600651
userMsg("4", "Fourth message"),
601652
markerMsg("marker1"),
653+
assistantMsg(
654+
"assistant-buffer",
655+
"Normal reply between compaction attempts"
656+
),
602657
markerMsg("marker2"),
603658
userMsg("interrupted", "User interrupted compaction with this message"),
604659
markerMsg("marker3"),

packages/scout-agent/lib/compaction.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { Message } from "./types";
1212
// Constants
1313
export const COMPACTION_MARKER_TOOL_NAME = "__compaction_marker";
1414
export const COMPACT_CONVERSATION_TOOL_NAME = "compact_conversation";
15+
export const MAX_CONSECUTIVE_COMPACTION_ATTEMPTS = 5;
1516

1617
// Error patterns for out-of-context detection (regex)
1718
const OUT_OF_CONTEXT_PATTERNS = [
@@ -165,6 +166,15 @@ function isCompactionSummaryPart(part: Message["parts"][number]): boolean {
165166
);
166167
}
167168

169+
function isCompactConversationPart(part: Message["parts"][number]): boolean {
170+
return (
171+
part.type === `tool-${COMPACT_CONVERSATION_TOOL_NAME}` ||
172+
(part.type === "dynamic-tool" &&
173+
"toolName" in part &&
174+
part.toolName === COMPACT_CONVERSATION_TOOL_NAME)
175+
);
176+
}
177+
168178
export interface CompactionMarkerPart {
169179
type: "dynamic-tool";
170180
toolName: typeof COMPACTION_MARKER_TOOL_NAME;
@@ -259,6 +269,38 @@ export function countCompactionMarkers(
259269
return count;
260270
}
261271

272+
/**
273+
* Finds the maximum number of consecutive assistant messages that contain
274+
* compaction tool calls. The streak resets when a non-assistant message
275+
* is encountered.
276+
*
277+
* @param messages - The message history to analyze
278+
* @returns The longest streak of consecutive compaction attempts
279+
*/
280+
export function maxConsecutiveCompactionAttempts(messages: Message[]): number {
281+
let maxAttempts = 0;
282+
let attempts = 0;
283+
284+
for (let i = messages.length - 1; i >= 0; i--) {
285+
const message = messages[i];
286+
if (!message) {
287+
continue;
288+
}
289+
if (message.role !== "assistant") {
290+
attempts = 0;
291+
}
292+
const hasCompactionPart = message.parts.some((part) =>
293+
isCompactConversationPart(part)
294+
);
295+
if (hasCompactionPart) {
296+
attempts++;
297+
maxAttempts = Math.max(maxAttempts, attempts);
298+
}
299+
}
300+
301+
return maxAttempts;
302+
}
303+
262304
/**
263305
* Build the compaction request message that instructs the model to compact.
264306
*/
@@ -438,6 +480,14 @@ function transformMessagesForCompaction(messages: Message[]): Message[] {
438480
* @throws {CompactionError} If compaction would leave no messages (too many retries)
439481
*/
440482
export function applyCompactionToMessages(messages: Message[]): Message[] {
483+
const compactionAttempts = maxConsecutiveCompactionAttempts(messages);
484+
if (compactionAttempts >= MAX_CONSECUTIVE_COMPACTION_ATTEMPTS) {
485+
throw new CompactionError(
486+
`Compaction loop detected after ${compactionAttempts} attempts`,
487+
compactionAttempts
488+
);
489+
}
490+
441491
const currentConversation = applySummaryToMessages(messages);
442492
const transformedMessages =
443493
transformMessagesForCompaction(currentConversation);

packages/scout-agent/lib/core.test.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,24 @@ describe("compaction", () => {
11281128
return textPart ? (textPart as { text: string }).text : undefined;
11291129
};
11301130

1131+
const createCompactionSummaryMessage = (id: string): Message => ({
1132+
id,
1133+
role: "assistant",
1134+
parts: [
1135+
{
1136+
type: "dynamic-tool",
1137+
toolName: "compact_conversation",
1138+
toolCallId: `${id}-call`,
1139+
state: "output-available",
1140+
input: { summary: "Test summary" },
1141+
output: {
1142+
summary: "Test summary",
1143+
compacted_at: "2024-01-01T00:00:00Z",
1144+
},
1145+
} as Message["parts"][number],
1146+
],
1147+
});
1148+
11311149
test("buildStreamTextParams always includes compact_conversation tool by default", async () => {
11321150
const agent = new blink.Agent<Message>();
11331151
const scout = new Scout({
@@ -1163,11 +1181,46 @@ describe("compaction", () => {
11631181
expect(params.tools.compact_conversation).toBeUndefined();
11641182
});
11651183

1166-
test("buildStreamTextParams throws when exclusion would leave insufficient messages", async () => {
1184+
test("buildStreamTextParams disables compaction after repeated compaction attempts", async () => {
1185+
const warn = mock();
1186+
const logger = { ...noopLogger, warn };
11671187
const agent = new blink.Agent<Message>();
11681188
const scout = new Scout({
11691189
agent,
1170-
logger: noopLogger,
1190+
logger,
1191+
});
1192+
1193+
const messages: Message[] = [
1194+
{
1195+
id: "user-1",
1196+
role: "user",
1197+
parts: [{ type: "text", text: "Hello" }],
1198+
},
1199+
createCompactionSummaryMessage("summary-1"),
1200+
createCompactionSummaryMessage("summary-2"),
1201+
createCompactionSummaryMessage("summary-3"),
1202+
createCompactionSummaryMessage("summary-4"),
1203+
createCompactionSummaryMessage("summary-5"),
1204+
];
1205+
1206+
const params = await scout.buildStreamTextParams({
1207+
chatID: "test-chat-id" as blink.ID,
1208+
messages,
1209+
model: newMockModel({ textResponse: "test" }),
1210+
});
1211+
1212+
expect(params.tools.compact_conversation).toBeUndefined();
1213+
expect(params.experimental_transform).toBeUndefined();
1214+
expect(warn).toHaveBeenCalled();
1215+
});
1216+
1217+
test("buildStreamTextParams disables compaction when exclusion would leave insufficient messages", async () => {
1218+
const warn = mock();
1219+
const logger = { ...noopLogger, warn };
1220+
const agent = new blink.Agent<Message>();
1221+
const scout = new Scout({
1222+
agent,
1223+
logger,
11711224
});
11721225

11731226
// Create messages with insufficient content to summarize after exclusion
@@ -1196,13 +1249,15 @@ describe("compaction", () => {
11961249
},
11971250
];
11981251

1199-
await expect(
1200-
scout.buildStreamTextParams({
1201-
chatID: "test-chat-id" as blink.ID,
1202-
messages,
1203-
model: newMockModel({ textResponse: "test" }),
1204-
})
1205-
).rejects.toThrow(/Cannot compact/);
1252+
const params = await scout.buildStreamTextParams({
1253+
chatID: "test-chat-id" as blink.ID,
1254+
messages,
1255+
model: newMockModel({ textResponse: "test" }),
1256+
});
1257+
1258+
expect(params.tools.compact_conversation).toBeUndefined();
1259+
expect(params.experimental_transform).toBeUndefined();
1260+
expect(warn).toHaveBeenCalled();
12061261
});
12071262

12081263
test("e2e: complete compaction flow using scout methods directly", async () => {

packages/scout-agent/lib/core.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import type * as blink from "blink";
1515
import {
1616
applyCompactionToMessages,
17+
CompactionError,
1718
createCompactionTransform,
1819
createCompactionTool,
1920
} from "./compaction";
@@ -469,6 +470,25 @@ export class Scout {
469470
}
470471
}
471472

473+
let compactionEnabled = compaction;
474+
let messagesToConvert = messages;
475+
if (compactionEnabled) {
476+
try {
477+
messagesToConvert = applyCompactionToMessages(messages);
478+
} catch (error) {
479+
if (error instanceof CompactionError) {
480+
this.logger.warn(
481+
"Disabling compaction due to repeated compaction failures",
482+
error
483+
);
484+
compactionEnabled = false;
485+
messagesToConvert = messages;
486+
} else {
487+
throw error;
488+
}
489+
}
490+
}
491+
472492
const tools = {
473493
...(this.webSearch.config
474494
? createWebSearchTools({ exaApiKey: this.webSearch.config.exaApiKey })
@@ -485,7 +505,7 @@ export class Scout {
485505
: undefined),
486506
...computeTools,
487507
// Always include compaction tool when compaction is enabled (for caching purposes)
488-
...(compaction ? createCompactionTool() : {}),
508+
...(compactionEnabled ? createCompactionTool() : {}),
489509
...providedTools,
490510
};
491511

@@ -498,10 +518,6 @@ ${slack.formattingRules}
498518
</formatting-rules>`;
499519
}
500520

501-
const messagesToConvert = compaction
502-
? applyCompactionToMessages(messages)
503-
: messages;
504-
505521
const converted = convertToModelMessages(messagesToConvert, {
506522
ignoreIncompleteToolCalls: true,
507523
tools,
@@ -525,7 +541,7 @@ ${slack.formattingRules}
525541
maxOutputTokens: 64_000,
526542
providerOptions,
527543
tools: withModelIntent(tools),
528-
experimental_transform: compaction
544+
experimental_transform: compactionEnabled
529545
? createCompactionTransform()
530546
: undefined,
531547
};

0 commit comments

Comments
 (0)