Skip to content

Commit 9592e54

Browse files
committed
better out of context error detection
1 parent 6fae75c commit 9592e54

File tree

5 files changed

+183
-226
lines changed

5 files changed

+183
-226
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: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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,
@@ -9,6 +10,7 @@ import {
910
countCompactionMarkers,
1011
createCompactionMarkerPart,
1112
createCompactionTool,
13+
findAPICallError,
1214
findCompactionSummary,
1315
isOutOfContextError,
1416
} from "./compaction";
@@ -55,62 +57,77 @@ function summaryMsg(
5557
}
5658

5759
describe("isOutOfContextError", () => {
58-
test("returns true for Anthropic context limit errors", () => {
59-
expect(isOutOfContextError(new Error("max_tokens_exceeded"))).toBe(true);
60+
const createApiError = (message: string) =>
61+
new APICallError({
62+
message,
63+
url: "https://api.example.com",
64+
requestBodyValues: {},
65+
statusCode: 400,
66+
});
67+
68+
test("returns true for APICallError with context limit message", () => {
6069
expect(
61-
isOutOfContextError(new Error("The context window has been exceeded"))
70+
isOutOfContextError(
71+
createApiError("Input is too long for requested model")
72+
)
6273
).toBe(true);
63-
});
64-
65-
test("returns true for OpenAI context_length_exceeded errors", () => {
66-
expect(isOutOfContextError(new Error("context_length_exceeded"))).toBe(
74+
expect(isOutOfContextError(createApiError("context_length_exceeded"))).toBe(
6775
true
6876
);
6977
});
7078

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);
79+
test("returns true for APICallError in cause chain", () => {
80+
const apiError = createApiError("max_tokens_exceeded");
81+
const wrapper = new Error("Gateway error");
82+
(wrapper as { cause?: unknown }).cause = apiError;
83+
expect(isOutOfContextError(wrapper)).toBe(true);
7784
});
7885

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
86+
test("returns false for APICallError with unrelated message", () => {
87+
expect(isOutOfContextError(createApiError("authentication failed"))).toBe(
88+
false
8589
);
8690
});
8791

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);
92+
test("returns false for non-APICallError even if message matches pattern", () => {
93+
expect(isOutOfContextError(new Error("context_length_exceeded"))).toBe(
94+
false
95+
);
96+
expect(isOutOfContextError("input too long")).toBe(false);
9197
});
98+
});
99+
100+
describe("findAPICallError", () => {
101+
const createApiError = (message: string) =>
102+
new APICallError({
103+
message,
104+
url: "https://api.example.com",
105+
requestBodyValues: {},
106+
statusCode: 400,
107+
});
92108

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);
109+
test("returns the APICallError when provided directly", () => {
110+
const error = createApiError("test");
111+
expect(findAPICallError(error)).toBe(error);
97112
});
98113

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

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);
121+
test("returns APICallError from deep cause chain", () => {
122+
const apiError = createApiError("test");
123+
const wrapper = { cause: { cause: apiError } };
124+
expect(findAPICallError(wrapper)).toBe(apiError);
107125
});
108126

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);
127+
test("returns null when no APICallError present", () => {
128+
expect(findAPICallError(new Error("other"))).toBeNull();
129+
expect(findAPICallError("string")).toBeNull();
130+
expect(findAPICallError(null)).toBeNull();
114131
});
115132
});
116133

packages/scout-agent/lib/compaction.ts

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { type Tool, tool } from "ai";
1+
import {
2+
APICallError,
3+
type StreamTextTransform,
4+
type TextStreamPart,
5+
type Tool,
6+
type ToolSet,
7+
tool,
8+
} from "ai";
29
import { z } from "zod";
310
import type { Message } from "./types";
411

@@ -20,28 +27,77 @@ const OUT_OF_CONTEXT_PATTERNS = [
2027
/maximum.*tokens/i,
2128
];
2229

30+
/**
31+
* Recursively search for an APICallError in the error's cause chain.
32+
*/
33+
export function findAPICallError(error: unknown): APICallError | null {
34+
if (APICallError.isInstance(error)) {
35+
return error;
36+
}
37+
if (error && typeof error === "object" && "cause" in error) {
38+
const cause = (error as { cause?: unknown }).cause;
39+
return findAPICallError(cause);
40+
}
41+
return null;
42+
}
43+
2344
/**
2445
* Check if an error is an out-of-context error based on known patterns.
2546
*/
2647
export function isOutOfContextError(error: unknown): boolean {
27-
let message: string;
28-
29-
if (error instanceof Error) {
30-
message = error.message;
31-
} else if (typeof error === "string") {
32-
message = error;
33-
} else if (
34-
error !== null &&
35-
typeof error === "object" &&
36-
"message" in error &&
37-
typeof error.message === "string"
38-
) {
39-
message = error.message;
40-
} else {
48+
const apiError = findAPICallError(error);
49+
if (!apiError) {
4150
return false;
4251
}
52+
return OUT_OF_CONTEXT_PATTERNS.some((pattern) =>
53+
pattern.test(apiError.message)
54+
);
55+
}
4356

44-
return OUT_OF_CONTEXT_PATTERNS.some((pattern) => pattern.test(message));
57+
/**
58+
* Creates a stream transform that detects out-of-context errors and emits a compaction marker.
59+
*/
60+
export function createCompactionTransform<T extends ToolSet>(
61+
onCompactionTriggered?: () => void
62+
): StreamTextTransform<T> {
63+
return ({ stopStream }) =>
64+
new TransformStream<TextStreamPart<T>, TextStreamPart<T>>({
65+
transform(chunk, controller) {
66+
if (
67+
chunk?.type === "error" &&
68+
isOutOfContextError((chunk as { error?: unknown }).error)
69+
) {
70+
onCompactionTriggered?.();
71+
const markerPart = createCompactionMarkerPart();
72+
controller.enqueue({
73+
type: "tool-call",
74+
toolCallType: "function",
75+
toolCallId: markerPart.toolCallId,
76+
toolName: markerPart.toolName,
77+
input: markerPart.input,
78+
dynamic: true,
79+
} as TextStreamPart<T>);
80+
controller.enqueue({
81+
type: "tool-result",
82+
toolCallId: markerPart.toolCallId,
83+
toolName: markerPart.toolName,
84+
input: markerPart.input,
85+
output: markerPart.output,
86+
providerExecuted: false,
87+
dynamic: true,
88+
} as TextStreamPart<T>);
89+
controller.enqueue({
90+
type: "finish",
91+
finishReason: "tool-calls",
92+
logprobs: undefined,
93+
totalUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
94+
} as TextStreamPart<T>);
95+
stopStream();
96+
return;
97+
}
98+
controller.enqueue(chunk);
99+
},
100+
});
45101
}
46102

47103
/**
@@ -371,7 +427,7 @@ function transformMessagesForCompaction(messages: Message[]): Message[] {
371427
* would be excluded, throws `CompactionError`.
372428
*
373429
* ## Flow example
374-
* 1. Model hits context limit → `processStreamTextOutput` emits compaction marker
430+
* 1. Model hits context limit → compaction transform emits compaction marker
375431
* 2. Next iteration calls this function
376432
* 3. Messages are truncated + compaction request appended
377433
* 4. Model calls `compact_conversation` with summary

0 commit comments

Comments
 (0)