Skip to content

Commit 232e713

Browse files
authored
fix(scout-agent): better out of context error detection (#111)
1 parent 6fae75c commit 232e713

File tree

6 files changed

+366
-237
lines changed

6 files changed

+366
-237
lines changed

packages/scout-agent/agent.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ agent.on("chat", async ({ id, messages }) => {
6464
}),
6565
},
6666
});
67-
const stream = streamText(params);
68-
return scout.processStreamTextOutput(stream);
67+
return streamText(params);
6968
});
7069

7170
agent.serve();

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

Lines changed: 105 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
/** biome-ignore-all lint/style/noNonNullAssertion: fine for tests */
22
/** biome-ignore-all lint/suspicious/noExplicitAny: fine for tests */
33
import { describe, expect, test } from "bun:test";
4+
import { APICallError } from "ai";
45
import {
56
applyCompactionToMessages,
67
buildCompactionRequestMessage,
78
COMPACT_CONVERSATION_TOOL_NAME,
89
COMPACTION_MARKER_TOOL_NAME,
910
countCompactionMarkers,
11+
maxConsecutiveCompactionAttempts,
1012
createCompactionMarkerPart,
1113
createCompactionTool,
14+
findAPICallError,
1215
findCompactionSummary,
1316
isOutOfContextError,
17+
MAX_CONSECUTIVE_COMPACTION_ATTEMPTS,
1418
} from "./compaction";
1519
import type { Message } from "./types";
1620

@@ -55,62 +59,77 @@ function summaryMsg(
5559
}
5660

5761
describe("isOutOfContextError", () => {
58-
test("returns true for Anthropic context limit errors", () => {
59-
expect(isOutOfContextError(new Error("max_tokens_exceeded"))).toBe(true);
62+
const createApiError = (message: string) =>
63+
new APICallError({
64+
message,
65+
url: "https://api.example.com",
66+
requestBodyValues: {},
67+
statusCode: 400,
68+
});
69+
70+
test("returns true for APICallError with context limit message", () => {
6071
expect(
61-
isOutOfContextError(new Error("The context window has been exceeded"))
72+
isOutOfContextError(
73+
createApiError("Input is too long for requested model")
74+
)
6275
).toBe(true);
63-
});
64-
65-
test("returns true for OpenAI context_length_exceeded errors", () => {
66-
expect(isOutOfContextError(new Error("context_length_exceeded"))).toBe(
76+
expect(isOutOfContextError(createApiError("context_length_exceeded"))).toBe(
6777
true
6878
);
6979
});
7080

71-
test("returns true for generic token limit exceeded messages", () => {
72-
expect(isOutOfContextError(new Error("token limit exceeded"))).toBe(true);
73-
expect(
74-
isOutOfContextError(new Error("Token limit has been exceeded"))
75-
).toBe(true);
76-
expect(isOutOfContextError(new Error("maximum tokens reached"))).toBe(true);
81+
test("returns true for APICallError in cause chain", () => {
82+
const apiError = createApiError("max_tokens_exceeded");
83+
const wrapper = new Error("Gateway error");
84+
(wrapper as { cause?: unknown }).cause = apiError;
85+
expect(isOutOfContextError(wrapper)).toBe(true);
7786
});
7887

79-
test("returns true for context window errors", () => {
80-
expect(isOutOfContextError(new Error("context window exceeded"))).toBe(
81-
true
82-
);
83-
expect(isOutOfContextError(new Error("context length exceeded"))).toBe(
84-
true
88+
test("returns false for APICallError with unrelated message", () => {
89+
expect(isOutOfContextError(createApiError("authentication failed"))).toBe(
90+
false
8591
);
8692
});
8793

88-
test("returns true for input too long errors", () => {
89-
expect(isOutOfContextError(new Error("input is too long"))).toBe(true);
90-
expect(isOutOfContextError(new Error("prompt is too long"))).toBe(true);
94+
test("returns false for non-APICallError even if message matches pattern", () => {
95+
expect(isOutOfContextError(new Error("context_length_exceeded"))).toBe(
96+
false
97+
);
98+
expect(isOutOfContextError("input too long")).toBe(false);
9199
});
100+
});
101+
102+
describe("findAPICallError", () => {
103+
const createApiError = (message: string) =>
104+
new APICallError({
105+
message,
106+
url: "https://api.example.com",
107+
requestBodyValues: {},
108+
statusCode: 400,
109+
});
92110

93-
test("returns false for unrelated errors", () => {
94-
expect(isOutOfContextError(new Error("network error"))).toBe(false);
95-
expect(isOutOfContextError(new Error("authentication failed"))).toBe(false);
96-
expect(isOutOfContextError(new Error("rate limit exceeded"))).toBe(false);
111+
test("returns the APICallError when provided directly", () => {
112+
const error = createApiError("test");
113+
expect(findAPICallError(error)).toBe(error);
97114
});
98115

99-
test("handles string messages", () => {
100-
expect(isOutOfContextError("token limit exceeded")).toBe(true);
101-
expect(isOutOfContextError("some other error")).toBe(false);
116+
test("returns APICallError from single-level cause", () => {
117+
const apiError = createApiError("test");
118+
const wrapper = new Error("wrapper");
119+
(wrapper as { cause?: unknown }).cause = apiError;
120+
expect(findAPICallError(wrapper)).toBe(apiError);
102121
});
103122

104-
test("handles objects with message property", () => {
105-
expect(isOutOfContextError({ message: "token limit exceeded" })).toBe(true);
106-
expect(isOutOfContextError({ message: "some other error" })).toBe(false);
123+
test("returns APICallError from deep cause chain", () => {
124+
const apiError = createApiError("test");
125+
const wrapper = { cause: { cause: apiError } };
126+
expect(findAPICallError(wrapper)).toBe(apiError);
107127
});
108128

109-
test("returns false for non-error values", () => {
110-
expect(isOutOfContextError(null)).toBe(false);
111-
expect(isOutOfContextError(undefined)).toBe(false);
112-
expect(isOutOfContextError(123)).toBe(false);
113-
expect(isOutOfContextError({})).toBe(false);
129+
test("returns null when no APICallError present", () => {
130+
expect(findAPICallError(new Error("other"))).toBeNull();
131+
expect(findAPICallError("string")).toBeNull();
132+
expect(findAPICallError(null)).toBeNull();
114133
});
115134
});
116135

@@ -285,6 +304,37 @@ describe("countCompactionMarkers", () => {
285304
});
286305
});
287306

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("does not count non-consecutive compaction attempts", () => {
319+
const messages: Message[] = [
320+
summaryMsg("summary-1", "First summary"),
321+
userMsg("1", "Hello"),
322+
summaryMsg("summary-2", "Second summary"),
323+
];
324+
325+
expect(maxConsecutiveCompactionAttempts(messages)).toBe(1);
326+
});
327+
328+
test("stops at non-compaction assistant message", () => {
329+
const messages: Message[] = [
330+
markerMsg("marker1"),
331+
assistantMsg("assistant", "Normal reply"),
332+
];
333+
334+
expect(maxConsecutiveCompactionAttempts(messages)).toBe(0);
335+
});
336+
});
337+
288338
describe("buildCompactionRequestMessage", () => {
289339
test("creates user message with correct role", () => {
290340
const message = buildCompactionRequestMessage();
@@ -311,6 +361,20 @@ describe("applyCompactionToMessages", () => {
311361
expect(result).toEqual(messages);
312362
});
313363

364+
test("throws when consecutive compaction attempts hit the limit", () => {
365+
const attempts = MAX_CONSECUTIVE_COMPACTION_ATTEMPTS + 1;
366+
const messages: Message[] = [
367+
userMsg("1", "Hello"),
368+
...Array.from({ length: attempts }, (_, idx) =>
369+
summaryMsg(`summary-${idx}`, `Summary ${idx}`)
370+
),
371+
];
372+
373+
expect(() => applyCompactionToMessages(messages)).toThrow(
374+
/Compaction loop detected/
375+
);
376+
});
377+
314378
test("excludes correct number of messages based on marker count", () => {
315379
const messages: Message[] = [
316380
userMsg("1", "Message 1"),
@@ -582,6 +646,10 @@ describe("applyCompactionToMessages", () => {
582646
userMsg("3", "Third message"),
583647
userMsg("4", "Fourth message"),
584648
markerMsg("marker1"),
649+
assistantMsg(
650+
"assistant-buffer",
651+
"Normal reply between compaction attempts"
652+
),
585653
markerMsg("marker2"),
586654
userMsg("interrupted", "User interrupted compaction with this message"),
587655
markerMsg("marker3"),

0 commit comments

Comments
 (0)