fix: handle "prompt is too long" errors automatically

- Add error detection for Anthropic "prompt is too long" errors
- Implement automatic context reduction when token limits exceeded
- Use conservative token buffering (15%) for small context windows (≤250k)
- Add pre-request validation to catch token overages before API calls
- Provide user feedback when automatic context reduction occurs
- Add comprehensive tests for error handling scenarios

Fixes #5816
This commit is contained in:
Roo Code 2025-07-17 14:57:34 +00:00
parent fb374b3e94
commit a6738ab5ad
8 changed files with 421 additions and 2 deletions

View file

@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var nextConfig = {
webpack: function (config) {
config.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".js", ".jsx"] };
return config;
},
};
exports.default = nextConfig;

View file

@ -0,0 +1,69 @@
"use server";
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getExercises = void 0;
var path = require("path");
var url_1 = require("url");
var evals_1 = require("@roo-code/evals");
var __dirname = path.dirname((0, url_1.fileURLToPath)(import.meta.url)); // <repo>/apps/web-evals/src/actions
var EVALS_REPO_PATH = path.resolve(__dirname, "../../../../../evals");
var getExercises = function () { return __awaiter(void 0, void 0, void 0, function () {
var result;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, Promise.all(evals_1.exerciseLanguages.map(function (language) { return __awaiter(void 0, void 0, void 0, function () {
var languagePath, exercises;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
languagePath = path.join(EVALS_REPO_PATH, language);
return [4 /*yield*/, (0, evals_1.listDirectories)(__dirname, languagePath)];
case 1:
exercises = _a.sent();
return [2 /*return*/, exercises.map(function (exercise) { return "".concat(language, "/").concat(exercise); })];
}
});
}); }))];
case 1:
result = _a.sent();
return [2 /*return*/, result.flat()];
}
});
}); };
exports.getExercises = getExercises;

View file

View file

@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var config_1 = require("vitest/config");
exports.default = (0, config_1.defineConfig)({
test: {
globals: true,
watch: false,
},
});

View file

@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { AnthropicHandler } from "../anthropic"
import type { ApiHandlerOptions } from "../../../shared/api"
// Mock the Anthropic SDK
vi.mock("@anthropic-ai/sdk", () => {
const mockCreate = vi.fn()
const mockCountTokens = vi.fn()
return {
Anthropic: vi.fn().mockImplementation(() => ({
messages: {
create: mockCreate,
countTokens: mockCountTokens,
},
})),
}
})
describe("AnthropicHandler - Prompt Too Long Error Handling", () => {
let handler: AnthropicHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
mockOptions = {
apiKey: "test-api-key",
apiModelId: "claude-3-5-sonnet-20241022",
}
handler = new AnthropicHandler(mockOptions)
})
describe("isPromptTooLongError", () => {
it("should detect prompt too long error messages", () => {
const error1 = { message: "prompt is too long: 208732 tokens > 200000 maximum" }
const error2 = { error: { message: "Input is too long: 205000 tokens > 200000 maximum", type: "invalid_request_error" } }
const error3 = { message: "Request contains 210000 tokens > 200000 maximum allowed" }
const error4 = { name: "PromptTooLongError", message: "PROMPT_TOO_LONG: some error" }
expect((handler as any).isPromptTooLongError(error1)).toBe(true)
expect((handler as any).isPromptTooLongError(error2)).toBe(true)
expect((handler as any).isPromptTooLongError(error3)).toBe(true)
expect((handler as any).isPromptTooLongError(error4)).toBe(true)
})
it("should not detect other types of errors", () => {
const error1 = { message: "Rate limit exceeded" }
const error2 = { error: { message: "Invalid API key", type: "authentication_error" } }
const error3 = { message: "Network error" }
expect((handler as any).isPromptTooLongError(error1)).toBe(false)
expect((handler as any).isPromptTooLongError(error2)).toBe(false)
expect((handler as any).isPromptTooLongError(error3)).toBe(false)
})
it("should handle null/undefined errors", () => {
expect((handler as any).isPromptTooLongError(null)).toBe(false)
expect((handler as any).isPromptTooLongError(undefined)).toBe(false)
expect((handler as any).isPromptTooLongError({})).toBe(false)
})
})
describe("completePrompt error handling", () => {
it("should re-throw prompt too long errors with enhanced error type", async () => {
const originalError = {
message: "prompt is too long: 208732 tokens > 200000 maximum",
error: { type: "invalid_request_error" }
}
const mockCreate = vi.fn().mockRejectedValue(originalError)
;(handler as any).client.messages.create = mockCreate
try {
await handler.completePrompt("test prompt")
expect.fail("Should have thrown an error")
} catch (error: any) {
expect(error.name).toBe("PromptTooLongError")
expect(error.message).toContain("PROMPT_TOO_LONG:")
expect(error.originalError).toBe(originalError)
expect(error.needsContextReduction).toBe(true)
}
})
it("should pass through other errors unchanged", async () => {
const originalError = { message: "Rate limit exceeded" }
const mockCreate = vi.fn().mockRejectedValue(originalError)
;(handler as any).client.messages.create = mockCreate
try {
await handler.completePrompt("test prompt")
expect.fail("Should have thrown an error")
} catch (error: any) {
expect(error).toBe(originalError)
expect(error.name).not.toBe("PromptTooLongError")
}
})
})
describe("createMessage error handling", () => {
it("should re-throw prompt too long errors with enhanced error type", async () => {
const originalError = {
message: "prompt is too long: 208732 tokens > 200000 maximum",
error: { type: "invalid_request_error" }
}
const mockCreate = vi.fn().mockRejectedValue(originalError)
;(handler as any).client.messages.create = mockCreate
try {
const stream = handler.createMessage("system prompt", [{ role: "user", content: "test" }])
await stream.next() // This should trigger the error
expect.fail("Should have thrown an error")
} catch (error: any) {
expect(error.name).toBe("PromptTooLongError")
expect(error.message).toContain("PROMPT_TOO_LONG:")
expect(error.originalError).toBe(originalError)
expect(error.needsContextReduction).toBe(true)
}
})
it("should pass through other errors unchanged", async () => {
const originalError = { message: "Rate limit exceeded" }
const mockCreate = vi.fn().mockRejectedValue(originalError)
;(handler as any).client.messages.create = mockCreate
try {
const stream = handler.createMessage("system prompt", [{ role: "user", content: "test" }])
await stream.next()
expect.fail("Should have thrown an error")
} catch (error: any) {
expect(error).toBe(originalError)
expect(error.name).not.toBe("PromptTooLongError")
}
})
})
})

View file

@ -296,4 +296,20 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
return super.countTokens(content)
}
}
/**
* Checks if an error is a "prompt is too long" error from Anthropic
*/
private isPromptTooLongError(error: any): boolean {
if (!error) return false
// Check for the specific error message pattern
const errorMessage = error.message || error.error?.message || ""
return (
errorMessage.includes("prompt is too long") ||
errorMessage.includes("tokens > ") ||
(error.error?.type === "invalid_request_error" && errorMessage.includes("maximum"))
)
}
}

View file

@ -11,6 +11,16 @@ import { ApiMessage } from "../task-persistence/apiMessages"
*/
export const TOKEN_BUFFER_PERCENTAGE = 0.1
/**
* More conservative buffer for models with smaller context windows (like Sonnet with 200k tokens)
*/
export const CONSERVATIVE_TOKEN_BUFFER_PERCENTAGE = 0.15
/**
* Context window threshold below which we use more conservative buffering
*/
export const SMALL_CONTEXT_WINDOW_THRESHOLD = 250_000
/**
* Counts tokens for user content using the provider's token counting implementation.
*
@ -118,8 +128,11 @@ export async function truncateConversationIfNeeded({
const prevContextTokens = totalTokens + lastMessageTokens
// Calculate available tokens for conversation history
// Truncate if we're within TOKEN_BUFFER_PERCENTAGE of the context window
const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens
// Use more conservative buffer for smaller context windows to prevent "prompt is too long" errors
const bufferPercentage = contextWindow <= SMALL_CONTEXT_WINDOW_THRESHOLD
? CONSERVATIVE_TOKEN_BUFFER_PERCENTAGE
: TOKEN_BUFFER_PERCENTAGE
const allowedTokens = contextWindow * (1 - bufferPercentage) - reservedTokens
// Determine the effective threshold to use
let effectiveThreshold = autoCondenseContextPercent

View file

@ -1655,6 +1655,62 @@ export class Task extends EventEmitter<ClineEvents> {
})()
}
/**
* Checks if an error is a "prompt is too long" error from Anthropic
*/
private isPromptTooLongError(error: any): boolean {
if (!error) return false
// Check for the specific error message pattern
const errorMessage = error.message || error.error?.message || ""
return (
errorMessage.includes("prompt is too long") ||
errorMessage.includes("tokens > ") ||
(error.error?.type === "invalid_request_error" && errorMessage.includes("maximum")) ||
error.name === "PromptTooLongError"
)
}
/**
* Validates that the conversation doesn't exceed the model's context window
* Returns true if validation passes, false if context needs reduction
*/
private async validateContextSize(systemPrompt: string, messages: ApiMessage[]): Promise<boolean> {
try {
const modelInfo = this.api.getModel().info
const contextWindow = modelInfo.contextWindow
// Estimate total tokens including system prompt
let totalTokens = 0
// Count system prompt tokens
const systemPromptTokens = await this.api.countTokens([{ type: "text", text: systemPrompt }])
totalTokens += systemPromptTokens
// Count message tokens
for (const message of messages) {
if (Array.isArray(message.content)) {
const messageTokens = await this.api.countTokens(message.content)
totalTokens += messageTokens
} else if (typeof message.content === "string") {
const messageTokens = await this.api.countTokens([{ type: "text", text: message.content }])
totalTokens += messageTokens
}
}
// Use the same buffer logic as sliding window
const bufferPercentage = contextWindow <= 250_000 ? 0.15 : 0.1
const maxAllowedTokens = contextWindow * (1 - bufferPercentage)
console.log(`Token validation: ${totalTokens}/${maxAllowedTokens} (${Math.round(totalTokens/contextWindow*100)}% of context window)`)
return totalTokens <= maxAllowedTokens
} catch (error) {
console.warn("Token validation failed, proceeding with request:", error)
return true // If validation fails, proceed anyway
}
}
public async *attemptApiRequest(retryAttempt: number = 0): ApiStream {
const state = await this.providerRef.deref()?.getState()
const {
@ -1773,6 +1829,62 @@ export class Task extends EventEmitter<ClineEvents> {
({ role, content }) => ({ role, content }),
)
// Pre-validate context size to catch potential overages before API call
const isContextValid = await this.validateContextSize(systemPrompt, cleanConversationHistory)
if (!isContextValid) {
console.log("Pre-request validation failed: context too large, forcing reduction...")
// Force more aggressive context reduction
const modelInfo = this.api.getModel().info
const maxTokens = getModelMaxOutputTokens({
modelId: this.api.getModel().id,
model: modelInfo,
settings: this.apiConfiguration,
})
const contextWindow = modelInfo.contextWindow
const truncateResult = await truncateConversationIfNeeded({
messages: this.apiConversationHistory,
totalTokens: contextWindow * 0.8, // Force truncation
maxTokens,
contextWindow,
apiHandler: this.api,
autoCondenseContext: true,
autoCondenseContextPercent: 40, // More aggressive threshold
systemPrompt,
taskId: this.taskId,
customCondensingPrompt,
condensingApiHandler,
profileThresholds,
currentProfileId: "default",
})
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)
// Update the conversation history for this request
const updatedMessagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory)
const updatedCleanConversationHistory = maybeRemoveImageBlocks(updatedMessagesSinceLastSummary, this.api).map(
({ role, content }) => ({ role, content }),
)
// Show user what happened
await this.say(
"error",
"The conversation context was approaching the model's limit. I've automatically reduced the context to prevent errors.",
undefined,
false,
undefined,
undefined,
{ isNonInteractive: true }
)
// Use the updated conversation history
cleanConversationHistory.length = 0
cleanConversationHistory.push(...updatedCleanConversationHistory)
}
}
// Check if we've reached the maximum number of auto-approved requests
const maxRequests = state?.allowedMaxRequests || Infinity
@ -1803,6 +1915,60 @@ export class Task extends EventEmitter<ClineEvents> {
this.isWaitingForFirstChunk = false
} catch (error) {
this.isWaitingForFirstChunk = false
// Check if this is a "prompt is too long" error that needs context reduction
if (this.isPromptTooLongError(error)) {
console.log("Detected 'prompt is too long' error, attempting context reduction...")
// Force context reduction by truncating more aggressively
const modelInfo = this.api.getModel().info
const maxTokens = getModelMaxOutputTokens({
modelId: this.api.getModel().id,
model: modelInfo,
settings: this.apiConfiguration,
})
const contextWindow = modelInfo.contextWindow
// Use a more aggressive truncation (remove 50% of messages)
const truncateResult = await truncateConversationIfNeeded({
messages: this.apiConversationHistory,
totalTokens: contextWindow * 0.9, // Force truncation by setting high token count
maxTokens,
contextWindow,
apiHandler: this.api,
autoCondenseContext: true,
autoCondenseContextPercent: 50, // More aggressive threshold
systemPrompt: await this.getSystemPrompt(),
taskId: this.taskId,
customCondensingPrompt: state?.customCondensingPrompt,
condensingApiHandler,
profileThresholds: {},
currentProfileId: "default",
})
if (truncateResult.messages !== this.apiConversationHistory) {
await this.overwriteApiConversationHistory(truncateResult.messages)
// Show user what happened
await this.say(
"error",
"The conversation context was too long for the model. I've automatically reduced the context and will retry the request.",
undefined,
false,
undefined,
undefined,
{ isNonInteractive: true }
)
// Retry the request with reduced context
yield* this.attemptApiRequest(retryAttempt)
return
} else {
// If truncation didn't help, fall through to normal error handling
console.warn("Context reduction did not help with prompt too long error")
}
}
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (autoApprovalEnabled && alwaysApproveResubmit) {
let errorMsg