diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 9ebc239f7ff..084b9e2dbd2 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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 { diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index d73266c62de..1701877790a 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -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; +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; + 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(), + 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