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,
|
||||
isBillingErrorMessage,
|
||||
isLikelyContextOverflowError,
|
||||
isOverloadedErrorMessage,
|
||||
isRateLimitErrorMessage,
|
||||
isTransientHttpError,
|
||||
sanitizeUserFacingText,
|
||||
} from "../../agents/pi-embedded-helpers.js";
|
||||
@@ -648,13 +650,43 @@ export async function runAgentTurnWithFallback(params: {
|
||||
// overflow errors were returned as embedded error payloads.
|
||||
const finalEmbeddedError = runResult?.meta?.error;
|
||||
const hasPayloadText = runResult?.payloads?.some((p) => p.text?.trim());
|
||||
if (finalEmbeddedError && isContextOverflowError(finalEmbeddedError.message) && !hasPayloadText) {
|
||||
return {
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.",
|
||||
},
|
||||
};
|
||||
if (finalEmbeddedError && !hasPayloadText) {
|
||||
const errorMsg = finalEmbeddedError.message ?? "";
|
||||
if (isContextOverflowError(errorMsg)) {
|
||||
return {
|
||||
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 {
|
||||
|
||||
@@ -7,15 +7,28 @@ import type { ContextEngine } from "./types.js";
|
||||
* Supports async creation for engines that need DB connections etc.
|
||||
*/
|
||||
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
|
||||
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
|
||||
|
||||
type RegisterContextEngineForOwnerOptions = {
|
||||
allowSameOwnerRefresh?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry (module-level singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState");
|
||||
const CORE_CONTEXT_ENGINE_OWNER = "core";
|
||||
const PUBLIC_CONTEXT_ENGINE_OWNER = "public-sdk";
|
||||
|
||||
type ContextEngineRegistryState = {
|
||||
engines: Map<string, ContextEngineFactory>;
|
||||
engines: Map<
|
||||
string,
|
||||
{
|
||||
factory: ContextEngineFactory;
|
||||
owner: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
// Keep context-engine registrations process-global so duplicated dist chunks
|
||||
@@ -26,24 +39,69 @@ function getContextEngineRegistryState(): ContextEngineRegistryState {
|
||||
};
|
||||
if (!globalState[CONTEXT_ENGINE_REGISTRY_STATE]) {
|
||||
globalState[CONTEXT_ENGINE_REGISTRY_STATE] = {
|
||||
engines: new Map<string, ContextEngineFactory>(),
|
||||
engines: new Map(),
|
||||
};
|
||||
}
|
||||
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 {
|
||||
getContextEngineRegistryState().engines.set(id, factory);
|
||||
export function registerContextEngineForOwner(
|
||||
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.
|
||||
*/
|
||||
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()
|
||||
: defaultSlotIdForKey("contextEngine");
|
||||
|
||||
const factory = getContextEngineRegistryState().engines.get(engineId);
|
||||
if (!factory) {
|
||||
const entry = getContextEngineRegistryState().engines.get(engineId);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Context engine "${engineId}" is not registered. ` +
|
||||
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return factory();
|
||||
return entry.factory();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user