fix: detect Ollama "prompt too long" as context overflow error (#34019)

Merged via squash.

Prepared head SHA: 825a402f0f
Co-authored-by: lishuaigit <7495165+lishuaigit@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
lishuaigit
2026-03-17 09:57:33 +08:00
committed by GitHub
parent 6da9ba3267
commit 76500c7a78
20 changed files with 275 additions and 50 deletions

View File

@@ -274,6 +274,10 @@ Docs: https://docs.openclaw.ai
- Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte.
- Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh.
- Feishu/file uploads: preserve literal UTF-8 filenames in `im.file.create` so Chinese and other non-ASCII filenames no longer appear percent-encoded in chat. (#34262) Thanks @fabiaodemianyang and @KangShuaiFu.
- Agents/compaction safeguard: trim large kept `toolResult` payloads consistently for budgeting, pruning, and identifier seeding, then restore preserved payloads after prune so oversized safeguard summaries stay stable. (#44133) thanks @SayrWolfridge.
- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
- Agents/Ollama overflow: rewrite Ollama `prompt too long` API payloads through the normal context-overflow sanitizer so embedded sessions keep the friendly overflow copy and auto-compaction trigger. (#34019) thanks @lishuaigit.
## 2026.3.11

View File

@@ -13,6 +13,7 @@ beforeEach(() => {
code: 1,
signal: null,
killed: false,
termination: "exit",
});
});

View File

@@ -37,7 +37,7 @@ beforeEach(() => {
warn: warnMock,
child: () => logger,
};
return logger as ReturnType<typeof subsystemModule.createSubsystemLogger>;
return logger as unknown as ReturnType<typeof subsystemModule.createSubsystemLogger>;
});
});

View File

@@ -3,12 +3,12 @@ import {
createScopedAccountConfigAccessors,
formatAllowFromLowercase,
} from "openclaw/plugin-sdk/compat";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
buildChannelConfigSchema,
getChatChannelMeta,
normalizeAccountId,
TelegramConfigSchema,
type ChannelPlugin,
type OpenClawConfig,
} from "openclaw/plugin-sdk/telegram";
import { inspectTelegramAccount } from "./account-inspect.js";

View File

@@ -12,6 +12,7 @@ import {
resolveThreadSessionKeys,
type RoutePeer,
} from "openclaw/plugin-sdk/core";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import {
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
@@ -28,7 +29,6 @@ import {
resolveTelegramGroupToolPolicy,
TelegramConfigSchema,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type OpenClawConfig,
} from "openclaw/plugin-sdk/telegram";
import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js";

View File

@@ -230,7 +230,7 @@ export async function probeTlonAccount(account: ConfiguredTlonAccount) {
}
export async function startTlonGatewayAccount(
ctx: Parameters<NonNullable<ChannelPlugin["gateway"]>["startAccount"]>[0],
ctx: Parameters<NonNullable<NonNullable<ChannelPlugin["gateway"]>["startAccount"]>>[0],
) {
const account = ctx.account;
ctx.setStatus({

View File

@@ -27,12 +27,12 @@ const tlonSetupWizardProxy = {
resolveConfigured: async ({ cfg }) =>
await (await loadTlonChannelRuntime()).tlonSetupWizard.status.resolveConfigured({ cfg }),
resolveStatusLines: async ({ cfg, configured }) =>
await (
(await (
await loadTlonChannelRuntime()
).tlonSetupWizard.status.resolveStatusLines?.({
cfg,
configured,
}),
})) ?? [],
},
introNote: {
title: "Tlon setup",

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import type { DmPolicy } from "openclaw/plugin-sdk/whatsapp";
import {
DEFAULT_ACCOUNT_ID,
type DmPolicy,
formatCliCommand,
formatDocsLink,
normalizeAccountId,
@@ -21,7 +21,7 @@ const channel = "whatsapp" as const;
function mergeWhatsAppConfig(
cfg: OpenClawConfig,
patch: Partial<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>,
patch: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>>,
options?: { unsetOnUndefined?: string[] },
): OpenClawConfig {
const base = { ...(cfg.channels?.whatsapp ?? {}) } as Record<string, unknown>;

View File

@@ -35,6 +35,12 @@ describe("formatAssistantErrorText", () => {
);
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns context overflow for Ollama 'prompt too long' errors (#34005)", () => {
const msg = makeAssistantError(
'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}',
);
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns a reasoning-required message for mandatory reasoning endpoint errors", () => {
const msg = makeAssistantError(
"400 Reasoning is mandatory for this endpoint and cannot be disabled.",

View File

@@ -42,6 +42,14 @@ describe("sanitizeUserFacingText", () => {
);
});
it("sanitizes Ollama prompt-too-long payloads through the context-overflow path", () => {
const text =
'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}';
expect(sanitizeUserFacingText(text, { errorContext: true })).toContain(
"Context overflow: prompt too large for the model.",
);
});
it.each([
"Changelog note: we fixed false positives for `Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.` in 2026.2.9",
"nah it failed, hit a context overflow. the prompt was too large for the model. want me to retry it with a different approach?",

View File

@@ -97,6 +97,7 @@ export function isContextOverflowError(errorMessage?: string): boolean {
lower.includes("context length exceeded") ||
lower.includes("maximum context length") ||
lower.includes("prompt is too long") ||
lower.includes("prompt too long") ||
lower.includes("exceeds model context window") ||
lower.includes("model token limit") ||
(hasRequestSizeExceeds && hasContextWindow) ||
@@ -211,11 +212,12 @@ export function extractObservedOverflowTokenCount(errorMessage?: string): number
return undefined;
}
// Allow provider-wrapped API payloads such as "Ollama API error 400: {...}".
const ERROR_PAYLOAD_PREFIX_RE =
/^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i;
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i;
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
const ERROR_PREFIX_RE =
/^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i;
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;

View File

@@ -33,7 +33,6 @@ function sortStrings(values: readonly string[]) {
}
const contractRuntime = createNonExitingRuntime();
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
expect(["user", "group", "channel"]).toContain(entry.kind);
expect(typeof entry.id).toBe("string");

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
dedupeProfileIds,
ensureAuthProfileStore,
@@ -9,6 +12,7 @@ import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js";
import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import type { UsageProviderId } from "./provider-usage.types.js";
@@ -28,6 +32,39 @@ type UsageAuthState = {
agentDir?: string;
};
function parseGoogleUsageToken(apiKey: string): string {
try {
const parsed = JSON.parse(apiKey) as { token?: unknown };
if (typeof parsed?.token === "string") {
return parsed.token;
}
} catch {
// ignore
}
return apiKey;
}
function resolveLegacyZaiUsageToken(env: NodeJS.ProcessEnv): string | undefined {
try {
const authPath = path.join(
resolveRequiredHomeDir(env, os.homedir),
".pi",
"agent",
"auth.json",
);
if (!fs.existsSync(authPath)) {
return undefined;
}
const parsed = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<
string,
{ access?: string }
>;
return parsed["z-ai"]?.access || parsed.zai?.access;
} catch {
return undefined;
}
}
function resolveProviderApiKeyFromConfigAndStore(params: {
state: UsageAuthState;
providerIds: string[];
@@ -166,6 +203,52 @@ async function resolveProviderUsageAuthViaPlugin(params: {
};
}
async function resolveProviderUsageAuthFallback(params: {
state: UsageAuthState;
provider: UsageProviderId;
}): Promise<ProviderAuth | null> {
switch (params.provider) {
case "anthropic":
case "github-copilot":
case "openai-codex":
return await resolveOAuthToken(params);
case "google-gemini-cli": {
const auth = await resolveOAuthToken(params);
return auth ? { ...auth, token: parseGoogleUsageToken(auth.token) } : null;
}
case "zai": {
const apiKey = resolveProviderApiKeyFromConfigAndStore({
state: params.state,
providerIds: ["zai", "z-ai"],
envDirect: [params.state.env.ZAI_API_KEY, params.state.env.Z_AI_API_KEY],
});
if (apiKey) {
return { provider: "zai", token: apiKey };
}
const legacyToken = resolveLegacyZaiUsageToken(params.state.env);
return legacyToken ? { provider: "zai", token: legacyToken } : null;
}
case "minimax": {
const apiKey = resolveProviderApiKeyFromConfigAndStore({
state: params.state,
providerIds: ["minimax"],
envDirect: [params.state.env.MINIMAX_CODE_PLAN_KEY, params.state.env.MINIMAX_API_KEY],
});
return apiKey ? { provider: "minimax", token: apiKey } : null;
}
case "xiaomi": {
const apiKey = resolveProviderApiKeyFromConfigAndStore({
state: params.state,
providerIds: ["xiaomi"],
envDirect: [params.state.env.XIAOMI_API_KEY],
});
return apiKey ? { provider: "xiaomi", token: apiKey } : null;
}
default:
return null;
}
}
export async function resolveProviderAuths(params: {
providers: UsageProviderId[];
auth?: ProviderAuth[];
@@ -192,6 +275,14 @@ export async function resolveProviderAuths(params: {
});
if (pluginAuth) {
auths.push(pluginAuth);
continue;
}
const fallbackAuth = await resolveProviderUsageAuthFallback({
state,
provider,
});
if (fallbackAuth) {
auths.push(fallbackAuth);
}
}

View File

@@ -2,6 +2,13 @@ import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js";
import { resolveFetch } from "./fetch.js";
import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js";
import {
fetchClaudeUsage,
fetchCodexUsage,
fetchGeminiUsage,
fetchMinimaxUsage,
fetchZaiUsage,
} from "./provider-usage.fetch.js";
import {
DEFAULT_TIMEOUT_MS,
ignoredErrors,
@@ -15,6 +22,99 @@ import type {
UsageSummary,
} from "./provider-usage.types.js";
async function fetchCopilotUsageFallback(
token: string,
timeoutMs: number,
fetchFn: typeof fetch,
): Promise<ProviderUsageSnapshot> {
const res = await fetchFn("https://api.github.com/copilot_internal/user", {
headers: {
Authorization: `token ${token}`,
"Editor-Version": "vscode/1.96.2",
"User-Agent": "GitHubCopilotChat/0.26.7",
"X-Github-Api-Version": "2025-04-01",
},
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
return {
provider: "github-copilot",
displayName: PROVIDER_LABELS["github-copilot"],
windows: [],
error: `HTTP ${res.status}`,
};
}
const data = (await res.json()) as {
quota_snapshots?: {
premium_interactions?: { percent_remaining?: number | null };
chat?: { percent_remaining?: number | null };
};
copilot_plan?: string;
};
const windows = [];
const premiumRemaining = data.quota_snapshots?.premium_interactions?.percent_remaining;
if (premiumRemaining !== undefined && premiumRemaining !== null) {
windows.push({
label: "Premium",
usedPercent: Math.max(0, Math.min(100, 100 - premiumRemaining)),
});
}
const chatRemaining = data.quota_snapshots?.chat?.percent_remaining;
if (chatRemaining !== undefined && chatRemaining !== null) {
windows.push({ label: "Chat", usedPercent: Math.max(0, Math.min(100, 100 - chatRemaining)) });
}
return {
provider: "github-copilot",
displayName: PROVIDER_LABELS["github-copilot"],
windows,
plan: data.copilot_plan,
};
}
async function fetchProviderUsageSnapshotFallback(params: {
auth: ProviderAuth;
timeoutMs: number;
fetchFn: typeof fetch;
}): Promise<ProviderUsageSnapshot> {
switch (params.auth.provider) {
case "anthropic":
return await fetchClaudeUsage(params.auth.token, params.timeoutMs, params.fetchFn);
case "github-copilot":
return await fetchCopilotUsageFallback(params.auth.token, params.timeoutMs, params.fetchFn);
case "google-gemini-cli":
return await fetchGeminiUsage(
params.auth.token,
params.timeoutMs,
params.fetchFn,
"google-gemini-cli",
);
case "openai-codex":
return await fetchCodexUsage(
params.auth.token,
params.auth.accountId,
params.timeoutMs,
params.fetchFn,
);
case "zai":
return await fetchZaiUsage(params.auth.token, params.timeoutMs, params.fetchFn);
case "minimax":
return await fetchMinimaxUsage(params.auth.token, params.timeoutMs, params.fetchFn);
case "xiaomi":
return {
provider: "xiaomi",
displayName: PROVIDER_LABELS.xiaomi,
windows: [],
};
default:
return {
provider: params.auth.provider,
displayName: PROVIDER_LABELS[params.auth.provider],
windows: [],
error: "Unsupported provider",
};
}
}
type UsageSummaryOptions = {
now?: number;
timeoutMs?: number;
@@ -56,12 +156,11 @@ async function fetchProviderUsageSnapshot(params: {
if (pluginSnapshot) {
return pluginSnapshot;
}
return {
provider: params.auth.provider,
displayName: PROVIDER_LABELS[params.auth.provider],
windows: [],
error: "Unsupported provider",
};
return await fetchProviderUsageSnapshotFallback({
auth: params.auth,
timeoutMs: params.timeoutMs,
fetchFn: params.fetchFn,
});
}
export async function loadProviderUsageSummary(

View File

@@ -22,6 +22,10 @@ export type SecretFileReadResult =
error?: unknown;
};
function normalizeSecretReadError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function loadSecretFileSync(
filePath: string,
label: string,
@@ -39,11 +43,12 @@ export function loadSecretFileSync(
try {
previewStat = fs.lstatSync(resolvedPath);
} catch (error) {
const normalized = normalizeSecretReadError(error);
return {
ok: false,
resolvedPath,
error,
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(error)}`,
error: normalized,
message: `Failed to inspect ${label} file at ${resolvedPath}: ${String(normalized)}`,
};
}
@@ -75,8 +80,9 @@ export function loadSecretFileSync(
maxBytes,
});
if (!opened.ok) {
const error =
opened.reason === "validation" ? new Error("security validation failed") : opened.error;
const error = normalizeSecretReadError(
opened.reason === "validation" ? new Error("security validation failed") : opened.error,
);
return {
ok: false,
resolvedPath,
@@ -97,11 +103,12 @@ export function loadSecretFileSync(
}
return { ok: true, secret, resolvedPath };
} catch (error) {
const normalized = normalizeSecretReadError(error);
return {
ok: false,
resolvedPath,
error,
message: `Failed to read ${label} file at ${resolvedPath}: ${String(error)}`,
error: normalized,
message: `Failed to read ${label} file at ${resolvedPath}: ${String(normalized)}`,
};
} finally {
fs.closeSync(opened.fd);

View File

@@ -137,10 +137,7 @@ describe("warning filter", () => {
seenWarnings.find((warning) => warning.code === "OPENCLAW_TEST_WARNING"),
).toBeDefined();
expect(
seenWarnings.find(
(warning) =>
warning.code === "DEP0040" && warning.message === "The punycode module is deprecated.",
),
seenWarnings.find((warning) => warning.message === "The punycode module is deprecated."),
).toBeDefined();
expect(stderrWrites.join("")).toContain("Visible warning");
} finally {

View File

@@ -75,6 +75,20 @@ export function installProcessWarningFilter(): void {
if (shouldIgnoreWarning(normalizeWarningArgs(args))) {
return;
}
if (
args[0] instanceof Error &&
args[1] &&
typeof args[1] === "object" &&
!Array.isArray(args[1])
) {
const warning = args[0];
const emitted = Object.assign(new Error(warning.message), {
name: warning.name,
code: (warning as Error & { code?: string }).code,
});
process.emit("warning", emitted);
return;
}
return Reflect.apply(originalEmitWarning, process, args);
}) as typeof process.emitWarning;

View File

@@ -2,10 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { build } from "tsdown";
import { describe, expect, it } from "vitest";
import {
buildPluginSdkEntrySources,
buildPluginSdkPackageExports,
buildPluginSdkSpecifiers,
pluginSdkEntrypoints,
@@ -119,24 +117,16 @@ describe("plugin-sdk exports", () => {
});
it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => {
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-"));
const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-"));
const repoDistDir = path.join(process.cwd(), "dist");
try {
await build({
clean: true,
config: false,
dts: false,
entry: buildPluginSdkEntrySources(),
env: { NODE_ENV: "production" },
fixedExtension: false,
logLevel: "error",
outDir,
platform: "node",
});
await expect(fs.access(path.join(repoDistDir, "plugin-sdk"))).resolves.toBeUndefined();
for (const entry of pluginSdkEntrypoints) {
const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href);
const module = await import(
pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href
);
expect(module).toBeTypeOf("object");
}
@@ -144,8 +134,8 @@ describe("plugin-sdk exports", () => {
const consumerDir = path.join(fixtureDir, "consumer");
const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs");
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir");
await fs.mkdir(packageDir, { recursive: true });
await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir");
await fs.writeFile(
path.join(packageDir, "package.json"),
JSON.stringify(
@@ -178,7 +168,6 @@ describe("plugin-sdk exports", () => {
Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])),
);
} finally {
await fs.rm(outDir, { recursive: true, force: true });
await fs.rm(fixtureDir, { recursive: true, force: true });
}
});

View File

@@ -10,6 +10,7 @@ import {
resolveOwningPluginIdsForProvider,
resolvePluginProviders,
} from "./providers.js";
import { resolvePluginCacheInputs } from "./roots.js";
import type {
ProviderAuthDoctorHintContext,
ProviderAugmentModelCatalogContext,
@@ -76,8 +77,16 @@ function resolveHookProviderCacheBucket(params: {
return bucket;
}
function buildHookProviderCacheKey(params: { workspaceDir?: string; onlyPluginIds?: string[] }) {
return `${params.workspaceDir ?? ""}::${JSON.stringify(params.onlyPluginIds ?? [])}`;
function buildHookProviderCacheKey(params: {
workspaceDir?: string;
onlyPluginIds?: string[];
env?: NodeJS.ProcessEnv;
}) {
const { roots } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
env: params.env,
});
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.onlyPluginIds ?? [])}`;
}
export function resetProviderRuntimeHookCacheForTest(): void {
@@ -105,6 +114,7 @@ function resolveProviderPluginsForHooks(params: {
const cacheKey = buildHookProviderCacheKey({
workspaceDir: params.workspaceDir,
onlyPluginIds: params.onlyPluginIds,
env,
});
const cached = cacheBucket.get(cacheKey);
if (cached) {

View File

@@ -153,9 +153,7 @@ describe("stageBundledPluginRuntime", () => {
description: string;
acceptsArgs: boolean;
}>;
matchPluginCommand: (
commandBody: string,
) => {
matchPluginCommand: (commandBody: string) => {
command: { handler: ({ args }: { args?: string }) => Promise<{ text: string }> };
args?: string;
} | null;