mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 10:22:32 +00:00
fix: surface rate limit errors mid-turn instead of silent empty response (closes #36142)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
|||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
isBillingErrorMessage,
|
isBillingErrorMessage,
|
||||||
isLikelyContextOverflowError,
|
isLikelyContextOverflowError,
|
||||||
|
isOverloadedErrorMessage,
|
||||||
|
isRateLimitErrorMessage,
|
||||||
isTransientHttpError,
|
isTransientHttpError,
|
||||||
sanitizeUserFacingText,
|
sanitizeUserFacingText,
|
||||||
} from "../../agents/pi-embedded-helpers.js";
|
} from "../../agents/pi-embedded-helpers.js";
|
||||||
@@ -648,13 +650,43 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
// overflow errors were returned as embedded error payloads.
|
// overflow errors were returned as embedded error payloads.
|
||||||
const finalEmbeddedError = runResult?.meta?.error;
|
const finalEmbeddedError = runResult?.meta?.error;
|
||||||
const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim());
|
const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim());
|
||||||
if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) {
|
if (finalEmbeddedError && !hasPayloadText) {
|
||||||
return {
|
const errorMsg = finalEmbeddedError.message ?? "";
|
||||||
kind: "final",
|
if (isContextOverflowError(errorMsg)) {
|
||||||
payload: {
|
return {
|
||||||
text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.",
|
kind: "final",
|
||||||
},
|
payload: {
|
||||||
};
|
text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface rate limit and overload errors that occur mid-turn (after tool
|
||||||
|
// calls) instead of silently returning an empty response. See #36142.
|
||||||
|
// Only applies when the assistant produced no valid (non-error) reply text,
|
||||||
|
// so tool-level rate-limit messages don't override a successful turn.
|
||||||
|
{
|
||||||
|
const hasNonErrorContent = runResult?.payloads?.some(
|
||||||
|
(p) => !p.isError && (p.text?.trim() || (p.mediaUrls?.length ?? 0) > 0),
|
||||||
|
);
|
||||||
|
if (!hasNonErrorContent) {
|
||||||
|
const errorPayloadText =
|
||||||
|
runResult?.payloads?.find((p) => p.isError && p.text?.trim())?.text ?? "";
|
||||||
|
const metaErrorMsg = finalEmbeddedError?.message ?? "";
|
||||||
|
const errorCandidate = errorPayloadText || metaErrorMsg;
|
||||||
|
if (
|
||||||
|
errorCandidate &&
|
||||||
|
(isRateLimitErrorMessage(errorCandidate) || isOverloadedErrorMessage(errorCandidate))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "final",
|
||||||
|
payload: {
|
||||||
|
text: "⚠️ API rate limit reached mid-turn — the model couldn't generate a response after tool calls completed. Please try again in a moment.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,15 +7,28 @@ import type { ContextEngine } from "./types.js";
|
|||||||
* Supports async creation for engines that need DB connections etc.
|
* Supports async creation for engines that need DB connections etc.
|
||||||
*/
|
*/
|
||||||
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
|
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
|
||||||
|
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
|
||||||
|
|
||||||
|
type RegisterContextEngineForOwnerOptions = {
|
||||||
|
allowSameOwnerRefresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Registry (module-level singleton)
|
// Registry (module-level singleton)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState");
|
const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState");
|
||||||
|
const CORE_CONTEXT_ENGINE_OWNER = "core";
|
||||||
|
const PUBLIC_CONTEXT_ENGINE_OWNER = "public-sdk";
|
||||||
|
|
||||||
type ContextEngineRegistryState = {
|
type ContextEngineRegistryState = {
|
||||||
engines: Map<string, ContextEngineFactory>;
|
engines: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
factory: ContextEngineFactory;
|
||||||
|
owner: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep context-engine registrations process-global so duplicated dist chunks
|
// Keep context-engine registrations process-global so duplicated dist chunks
|
||||||
@@ -26,24 +39,69 @@ function getContextEngineRegistryState(): ContextEngineRegistryState {
|
|||||||
};
|
};
|
||||||
if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) {
|
if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) {
|
||||||
globalState[CONTEXT_ENGINE_REGISTRY_STATE] = {
|
globalState[CONTEXT_ENGINE_REGISTRY_STATE] = {
|
||||||
engines: new Map<string, ContextEngineFactory>(),
|
engines: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return globalState[CONTEXT_ENGINE_REGISTRY_STATE];
|
return globalState[CONTEXT_ENGINE_REGISTRY_STATE];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireContextEngineOwner(owner: string): string {
|
||||||
|
const normalizedOwner = owner.trim();
|
||||||
|
if (!normalizedOwner) {
|
||||||
|
throw new Error(
|
||||||
|
`registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalizedOwner;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a context engine implementation under the given id.
|
* Register a context engine implementation under an explicit trusted owner.
|
||||||
*/
|
*/
|
||||||
export function registerContextEngine(id: string, factory: ContextEngineFactory): void {
|
export function registerContextEngineForOwner(
|
||||||
getContextEngineRegistryState().engines.set(id, factory);
|
id: string,
|
||||||
|
factory: ContextEngineFactory,
|
||||||
|
owner: string,
|
||||||
|
opts?: RegisterContextEngineForOwnerOptions,
|
||||||
|
): ContextEngineRegistrationResult {
|
||||||
|
const normalizedOwner = requireContextEngineOwner(owner);
|
||||||
|
const registry = getContextEngineRegistryState().engines;
|
||||||
|
const existing = registry.get(id);
|
||||||
|
if (
|
||||||
|
id === defaultSlotIdForKey("contextEngine") &&
|
||||||
|
normalizedOwner !== CORE_CONTEXT_ENGINE_OWNER
|
||||||
|
) {
|
||||||
|
return { ok: false, existingOwner: CORE_CONTEXT_ENGINE_OWNER };
|
||||||
|
}
|
||||||
|
if (existing && existing.owner !== normalizedOwner) {
|
||||||
|
return { ok: false, existingOwner: existing.owner };
|
||||||
|
}
|
||||||
|
if (existing && opts?.allowSameOwnerRefresh !== true) {
|
||||||
|
return { ok: false, existingOwner: existing.owner };
|
||||||
|
}
|
||||||
|
registry.set(id, { factory, owner: normalizedOwner });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public SDK entry point for third-party registrations.
|
||||||
|
*
|
||||||
|
* This path is intentionally unprivileged: it cannot claim core-owned ids and
|
||||||
|
* it cannot safely refresh an existing registration because the caller's
|
||||||
|
* identity is not authenticated.
|
||||||
|
*/
|
||||||
|
export function registerContextEngine(
|
||||||
|
id: string,
|
||||||
|
factory: ContextEngineFactory,
|
||||||
|
): ContextEngineRegistrationResult {
|
||||||
|
return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the factory for a registered engine, or undefined.
|
* Return the factory for a registered engine, or undefined.
|
||||||
*/
|
*/
|
||||||
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
|
export function getContextEngineFactory(id: string): ContextEngineFactory | undefined {
|
||||||
return getContextEngineRegistryState().engines.get(id);
|
return getContextEngineRegistryState().engines.get(id)?.factory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,13 +131,13 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
|
|||||||
? slotValue.trim()
|
? slotValue.trim()
|
||||||
: defaultSlotIdForKey("contextEngine");
|
: defaultSlotIdForKey("contextEngine");
|
||||||
|
|
||||||
const factory = getContextEngineRegistryState().engines.get(engineId);
|
const entry = getContextEngineRegistryState().engines.get(engineId);
|
||||||
if (!factory) {
|
if (!entry) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Context engine "${engineId}" is not registered. ` +
|
`Context engine "${engineId}" is not registered. ` +
|
||||||
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
|
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return factory();
|
return entry.factory();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user