diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 31d702c5420..aa0ad8bdf56 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -82,6 +82,25 @@ describe("lookupContextTokens", () => { expect(lookupContextTokens("openrouter/claude-sonnet")).toBe(321_000); }); + it("can skip async warmup for read-only callers", async () => { + const { ensureOpenClawModelsJson } = mockContextModuleDeps(() => ({ + models: { + providers: { + openrouter: { + models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }], + }, + }, + }, + })); + + const { lookupContextTokens } = await import("./context.js"); + expect( + lookupContextTokens("openrouter/claude-sonnet", { allowAsyncLoad: false }), + ).toBeUndefined(); + await flushAsyncWarmup(); + expect(ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + it("only warms eagerly for real openclaw startup commands that need model metadata", async () => { const argvSnapshot = process.argv; try { diff --git a/src/agents/context.ts b/src/agents/context.ts index 841432e873e..030b350e451 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -225,12 +225,17 @@ function ensureContextWindowCacheLoaded(): Promise { return loadPromise; } -export function lookupContextTokens(modelId?: string): number | undefined { +export function lookupContextTokens( + modelId?: string, + options?: { allowAsyncLoad?: boolean }, +): number | undefined { if (!modelId) { return undefined; } // Best-effort: kick off loading on demand, but don't block lookups. - void ensureContextWindowCacheLoaded(); + if (options?.allowAsyncLoad !== false) { + void ensureContextWindowCacheLoaded(); + } return MODEL_CACHE.get(modelId); } @@ -354,6 +359,7 @@ export function resolveContextTokensForModel(params: { model?: string; contextTokensOverride?: number; fallbackContextTokens?: number; + allowAsyncLoad?: boolean; }): number | undefined { if (typeof params.contextTokensOverride === "number" && params.contextTokensOverride > 0) { return params.contextTokensOverride; @@ -402,6 +408,7 @@ export function resolveContextTokensForModel(params: { if (params.provider && ref && !ref.model.includes("/")) { const qualifiedResult = lookupContextTokens( `${normalizeProviderId(ref.provider)}/${ref.model}`, + { allowAsyncLoad: params.allowAsyncLoad }, ); if (qualifiedResult !== undefined) { return qualifiedResult; @@ -410,7 +417,9 @@ export function resolveContextTokensForModel(params: { // Bare key fallback. For model-only calls with slash-containing IDs // (e.g. "google/gemini-2.5-pro") this IS the raw discovery cache key. - const bareResult = lookupContextTokens(params.model); + const bareResult = lookupContextTokens(params.model, { + allowAsyncLoad: params.allowAsyncLoad, + }); if (bareResult !== undefined) { return bareResult; } @@ -421,6 +430,7 @@ export function resolveContextTokensForModel(params: { if (!params.provider && ref && !ref.model.includes("/")) { const qualifiedResult = lookupContextTokens( `${normalizeProviderId(ref.provider)}/${ref.model}`, + { allowAsyncLoad: params.allowAsyncLoad }, ); if (qualifiedResult !== undefined) { return qualifiedResult; diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 953e9b9f77a..267a7200739 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -5,7 +5,6 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveOsSummary } from "../infra/os-summary.js"; -import { buildPluginCompatibilityNotices } from "../plugins/status.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { getAgentLocalStatuses } from "./status.agent-local.js"; @@ -23,6 +22,7 @@ import { getStatusSummary } from "./status.summary.js"; import { getUpdateCheckResult } from "./status.update.js"; let pluginRegistryModulePromise: Promise | undefined; +let pluginStatusModulePromise: Promise | undefined; let configIoModulePromise: Promise | undefined; let commandSecretTargetsModulePromise: | Promise @@ -40,6 +40,11 @@ function loadPluginRegistryModule() { return pluginRegistryModulePromise; } +function loadPluginStatusModule() { + pluginStatusModulePromise ??= import("../plugins/status.js"); + return pluginStatusModulePromise; +} + function loadConfigIoModule() { configIoModulePromise ??= import("../config/io.js"); return configIoModulePromise; @@ -194,7 +199,12 @@ export async function scanStatusJsonFast( const memoryPlugin = resolveMemoryPluginStatus(cfg); const memory = await resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); const pluginCompatibility = shouldCollectPluginCompatibility(cfg) - ? buildPluginCompatibilityNotices({ config: cfg }) + ? await loadPluginStatusModule().then(({ buildPluginCompatibilityNotices }) => + // Keep plugin status loading off the empty-config `status --json` fast path. + // The plugin status module pulls in the full loader graph and materially bloats + // startup RSS even when plugin compatibility is never consulted. + buildPluginCompatibilityNotices({ config: cfg }), + ) : []; return { diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index 2f4f9ce260f..c441ce1d879 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -78,6 +78,7 @@ vi.mock("./status.link-channel.js", () => ({ })); const { hasPotentialConfiguredChannels } = await import("../channels/config-presence.js"); +const { resolveContextTokensForModel } = await import("../agents/context.js"); const { buildChannelSummary } = await import("../infra/channel-summary.js"); const { resolveLinkChannelContext } = await import("./status.link-channel.js"); const { getStatusSummary } = await import("./status.summary.js"); @@ -105,4 +106,12 @@ describe("getStatusSummary", () => { expect(buildChannelSummary).not.toHaveBeenCalled(); expect(resolveLinkChannelContext).not.toHaveBeenCalled(); }); + + it("does not trigger async context warmup while building status summaries", async () => { + await getStatusSummary(); + + expect(vi.mocked(resolveContextTokensForModel)).toHaveBeenCalledWith( + expect.objectContaining({ allowAsyncLoad: false }), + ); + }); }); diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index c235765b406..8911bee6caf 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -219,6 +219,9 @@ export async function getStatusSummary( model: configModel, contextTokensOverride: cfg.agents?.defaults?.contextTokens, fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, + // Keep `status`/`status --json` startup read-only. These summary lookups + // should not kick off background provider discovery or plugin scans. + allowAsyncLoad: false, }) ?? DEFAULT_CONTEXT_TOKENS; const now = Date.now(); @@ -250,6 +253,7 @@ export async function getStatusSummary( model, contextTokensOverride: entry?.contextTokens, fallbackContextTokens: configContextTokens ?? undefined, + allowAsyncLoad: false, }) ?? null; const total = resolveFreshSessionTotalTokens(entry); const totalTokensFresh =