From 58f81b0e04ef0304fc604f378fbcee6be01b1cde Mon Sep 17 00:00:00 2001 From: Edionwheels Date: Wed, 6 May 2026 15:54:52 +0800 Subject: [PATCH] fix(codex): honor OAuth contextTokens in native harness Fixes #77858. Co-authored-by: Edionwheels <267595845+lilesjtu@users.noreply.github.com> Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/agents/model-catalog.ts | 42 +++++++- src/agents/model-catalog.types.ts | 1 + src/agents/model-selection-shared.ts | 9 ++ src/agents/pi-embedded-runner/run.ts | 14 +++ .../pi-embedded-runner/run/setup.test.ts | 96 ++++++++++++++++++- src/agents/pi-embedded-runner/run/setup.ts | 3 +- src/auto-reply/reply/commands-status.test.ts | 57 +++++++++++ .../reply/directive-handling.model.test.ts | 36 ++++++- .../reply/directive-handling.persist.ts | 41 ++++++-- src/auto-reply/reply/model-selection.test.ts | 26 +++++ src/auto-reply/reply/model-selection.ts | 28 ++++-- src/auto-reply/status.test.ts | 48 ++++++++++ src/gateway/server.cron.test.ts | 10 +- .../npm-install-security-scan.release.test.ts | 5 +- src/status/status-message.ts | 39 +++++--- src/status/status-text.ts | 24 +++++ 17 files changed, 434 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d9e5eefa5..004037da388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,7 @@ Docs: https://docs.openclaw.ai - Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754) - WebChat/Codex media: stage Codex app-server generated local images into managed media before Gateway display, so Codex-home image paths no longer hit `LocalMediaAccessError` while keeping Codex home out of the display allowlist. Thanks @frankekn. - Plugins/update: repair plugin-local `openclaw` peer links for all recorded npm plugins after any npm update mutates the shared managed npm tree, so targeted or batch updates cannot leave Codex, Discord, or Brave with pruned SDK imports. (#77787) Thanks @ProspectOre. +- Codex harness: honor `models.providers.openai-codex.models[].contextTokens` for native `openai/*` Codex runtime runs and `/status` context reporting, so subscription-backed Codex agents use the configured OAuth context cap without inflating past the runtime model window. Fixes #77858. Thanks @lilesjtu. - TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc. - Doctor/gateway: report recent supervisor restart handoffs in `openclaw doctor --deep`, using the installed service environment when available so service-managed clean exits are visible in guided diagnostics. Thanks @shakkernerd. - Gateway/status: show recent supervisor restart handoffs in `openclaw gateway status --deep`, including JSON details, so clean service-managed restarts are reported as restart handoffs instead of opaque stopped-service diagnostics. Thanks @shakkernerd. diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index 7f24bbb9314..3d46b87970f 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -35,6 +35,7 @@ type DiscoveredModel = { name?: string; provider: string; contextWindow?: number; + contextTokens?: number; reasoning?: boolean; input?: ModelInputType[]; compat?: ModelCatalogEntry["compat"]; @@ -161,6 +162,9 @@ export function loadManifestModelCatalog(params: { if (contextWindow) { entry.contextWindow = contextWindow; } + if (row.contextTokens) { + entry.contextTokens = row.contextTokens; + } if (typeof row.reasoning === "boolean") { entry.reasoning = row.reasoning; } @@ -189,6 +193,7 @@ function normalizePersistedModelCatalogEntry( entry: Record, defaults?: { contextWindow?: number; + contextTokens?: number; }, ): ModelCatalogEntry | undefined { const id = normalizeOptionalString(entry.id) ?? ""; @@ -206,6 +211,12 @@ function normalizePersistedModelCatalogEntry( : defaults?.contextWindow !== undefined ? defaults.contextWindow : PI_CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW; + const contextTokens = + typeof entry?.contextTokens === "number" && entry.contextTokens > 0 + ? entry.contextTokens + : defaults?.contextTokens !== undefined + ? defaults.contextTokens + : undefined; const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : false; const parsedInput = Array.isArray(entry?.input) ? entry.input.filter((value): value is ModelInputType => @@ -217,7 +228,16 @@ function normalizePersistedModelCatalogEntry( entry?.compat && typeof entry.compat === "object" ? (entry.compat as ModelCatalogEntry["compat"]) : undefined; - return { id, name, provider, contextWindow, reasoning, input, compat }; + return { + id, + name, + provider, + contextWindow, + ...(contextTokens !== undefined ? { contextTokens } : {}), + reasoning, + input, + compat, + }; } async function loadReadOnlyPersistedModelCatalog(params?: { @@ -242,9 +262,14 @@ async function loadReadOnlyPersistedModelCatalog(params?: { typeof providerConfig?.contextWindow === "number" && providerConfig.contextWindow > 0 ? providerConfig.contextWindow : undefined; + const providerContextTokens = + typeof providerConfig?.contextTokens === "number" && providerConfig.contextTokens > 0 + ? providerConfig.contextTokens + : undefined; for (const entry of providerConfig.models as Record[]) { const normalized = normalizePersistedModelCatalogEntry(providerRaw, entry, { contextWindow: providerContextWindow, + contextTokens: providerContextTokens, }); if (normalized && !shouldSuppressBuiltInModel(normalized)) { models.push(normalized); @@ -370,10 +395,23 @@ export async function loadModelCatalog(params?: { typeof entry?.contextWindow === "number" && entry.contextWindow > 0 ? entry.contextWindow : undefined; + const contextTokens = + typeof entry?.contextTokens === "number" && entry.contextTokens > 0 + ? entry.contextTokens + : undefined; const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined; const input = Array.isArray(entry?.input) ? entry.input : undefined; const compat = entry?.compat && typeof entry.compat === "object" ? entry.compat : undefined; - models.push({ id, name, provider, contextWindow, reasoning, input, compat }); + models.push({ + id, + name, + provider, + contextWindow, + ...(contextTokens !== undefined ? { contextTokens } : {}), + reasoning, + input, + compat, + }); } if (!readOnly) { const supplemental = await augmentModelCatalogWithProviderPlugins({ diff --git a/src/agents/model-catalog.types.ts b/src/agents/model-catalog.types.ts index aeb6aada5c5..501f774e699 100644 --- a/src/agents/model-catalog.types.ts +++ b/src/agents/model-catalog.types.ts @@ -8,6 +8,7 @@ export type ModelCatalogEntry = { provider: string; alias?: string; contextWindow?: number; + contextTokens?: number; reasoning?: boolean; input?: ModelInputType[]; compat?: ModelCompatConfig; diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index b745670b37a..520fd5c2791 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -442,6 +442,7 @@ function applyModelCatalogMetadata(params: { } const nextAlias = alias ?? params.entry.alias; const nextContextWindow = configuredEntry?.contextWindow ?? params.entry.contextWindow; + const nextContextTokens = configuredEntry?.contextTokens ?? params.entry.contextTokens; const nextReasoning = configuredEntry?.reasoning ?? params.entry.reasoning; const nextInput = configuredEntry?.input ?? params.entry.input; const nextCompat = configuredEntry?.compat ?? params.entry.compat; @@ -451,6 +452,7 @@ function applyModelCatalogMetadata(params: { name: configuredEntry?.name ?? params.entry.name, ...(nextAlias ? { alias: nextAlias } : {}), ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), + ...(nextContextTokens !== undefined ? { contextTokens: nextContextTokens } : {}), ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), ...(nextInput ? { input: nextInput } : {}), ...(nextCompat ? { compat: nextCompat } : {}), @@ -465,6 +467,7 @@ function buildSyntheticAllowedCatalogEntry(params: { const configuredEntry = params.metadata.configuredByKey.get(key); const alias = params.metadata.aliasByKey.get(key); const nextContextWindow = configuredEntry?.contextWindow; + const nextContextTokens = configuredEntry?.contextTokens; const nextReasoning = configuredEntry?.reasoning; const nextInput = configuredEntry?.input; const nextCompat = configuredEntry?.compat; @@ -475,6 +478,7 @@ function buildSyntheticAllowedCatalogEntry(params: { provider: params.parsed.provider, ...(alias ? { alias } : {}), ...(nextContextWindow !== undefined ? { contextWindow: nextContextWindow } : {}), + ...(nextContextTokens !== undefined ? { contextTokens: nextContextTokens } : {}), ...(nextReasoning !== undefined ? { reasoning: nextReasoning } : {}), ...(nextInput ? { input: nextInput } : {}), ...(nextCompat ? { compat: nextCompat } : {}), @@ -836,6 +840,10 @@ export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig }): Mo typeof model?.contextWindow === "number" && model.contextWindow > 0 ? model.contextWindow : undefined; + const contextTokens = + typeof model?.contextTokens === "number" && model.contextTokens > 0 + ? model.contextTokens + : undefined; const reasoning = typeof model?.reasoning === "boolean" ? model.reasoning : undefined; const input = Array.isArray(model?.input) ? model.input : undefined; const compat = model?.compat && typeof model.compat === "object" ? model.compat : undefined; @@ -844,6 +852,7 @@ export function buildConfiguredModelCatalog(params: { cfg: OpenClawConfig }): Mo id, name, contextWindow, + contextTokens, reasoning, input, compat, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index be627aa2d1f..e1f79574521 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -181,6 +181,16 @@ const COMPACTION_CONTINUATION_RETRY_INSTRUCTION = "The previous attempt compacted the conversation context before producing a final user-visible answer. Continue from the compacted transcript and produce the final answer now. Do not restart from scratch, do not repeat completed work, and do not rerun tools unless the transcript clearly lacks required evidence."; type EmbeddedRunAttemptForRunner = Awaited>; +function resolveHarnessContextConfigProvider(params: { + provider: string; + harnessId: string; +}): string { + if (params.harnessId === "codex" && params.provider.trim().toLowerCase() === "openai") { + return "openai-codex"; + } + return params.provider; +} + function resolveEmbeddedRunLaneTimeoutMs(timeoutMs: number): number | undefined { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { return undefined; @@ -530,6 +540,10 @@ export async function runEmbeddedPiAgent( const resolvedRuntimeModel = resolveEffectiveRuntimeModel({ cfg: params.config, provider, + contextConfigProvider: resolveHarnessContextConfigProvider({ + provider, + harnessId: agentHarness.id, + }), modelId, runtimeModel, }); diff --git a/src/agents/pi-embedded-runner/run/setup.test.ts b/src/agents/pi-embedded-runner/run/setup.test.ts index 17ca0c80673..e6586e860e7 100644 --- a/src/agents/pi-embedded-runner/run/setup.test.ts +++ b/src/agents/pi-embedded-runner/run/setup.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { buildBeforeModelResolveAttachments, resolveHookModelSelection } from "./setup.js"; +import type { ModelDefinitionConfig } from "../../../config/types.models.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { ProviderRuntimeModel } from "../../../plugins/provider-runtime-model.types.js"; +import { + buildBeforeModelResolveAttachments, + resolveEffectiveRuntimeModel, + resolveHookModelSelection, +} from "./setup.js"; const hookContext = { sessionId: "session-1", @@ -73,3 +80,90 @@ describe("resolveHookModelSelection", () => { ); }); }); + +function createRuntimeModel(): ProviderRuntimeModel { + return { + provider: "openai", + id: "gpt-5.5", + name: "gpt-5.5", + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_050_000, + contextTokens: 272_000, + maxTokens: 128_000, + }; +} + +function createConfiguredModel( + overrides: Partial = {}, +): ModelDefinitionConfig { + return { + id: "gpt-5.5", + name: "gpt-5.5", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_050_000, + contextTokens: 1_000_000, + maxTokens: 128_000, + ...overrides, + }; +} + +describe("resolveEffectiveRuntimeModel", () => { + it("can read Codex OAuth context overrides for native Codex harness runs", () => { + const cfg = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [createConfiguredModel()], + }, + }, + }, + } satisfies OpenClawConfig; + + const result = resolveEffectiveRuntimeModel({ + cfg, + provider: "openai", + contextConfigProvider: "openai-codex", + modelId: "gpt-5.5", + runtimeModel: createRuntimeModel(), + }); + + expect(result.ctxInfo).toEqual({ + source: "modelsConfig", + tokens: 1_000_000, + }); + expect(result.effectiveModel.contextWindow).toBe(1_000_000); + }); + + it("keeps the runtime model contextTokens when no alternate context provider is supplied", () => { + const cfg = { + models: { + providers: { + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [createConfiguredModel()], + }, + }, + }, + } satisfies OpenClawConfig; + + const result = resolveEffectiveRuntimeModel({ + cfg, + provider: "openai", + modelId: "gpt-5.5", + runtimeModel: createRuntimeModel(), + }); + + expect(result.ctxInfo).toEqual({ + source: "model", + tokens: 272_000, + }); + expect(result.effectiveModel.contextWindow).toBe(272_000); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/setup.ts b/src/agents/pi-embedded-runner/run/setup.ts index 09a564f24ec..2d22e7320eb 100644 --- a/src/agents/pi-embedded-runner/run/setup.ts +++ b/src/agents/pi-embedded-runner/run/setup.ts @@ -117,6 +117,7 @@ export function buildBeforeModelResolveAttachments( export function resolveEffectiveRuntimeModel(params: { cfg: OpenClawConfig | undefined; provider: string; + contextConfigProvider?: string; modelId: string; runtimeModel: ProviderRuntimeModel; }): { @@ -125,7 +126,7 @@ export function resolveEffectiveRuntimeModel(params: { } { const ctxInfo = resolveContextWindowInfo({ cfg: params.cfg, - provider: params.provider, + provider: params.contextConfigProvider ?? params.provider, modelId: params.modelId, modelContextTokens: readPiModelContextTokens(params.runtimeModel), modelContextWindow: params.runtimeModel.contextWindow, diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 6c026b4210a..b86c92b603c 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -10,6 +10,7 @@ import { addSubagentRunForTests, resetSubagentRegistryForTests, } from "../../agents/subagent-registry.js"; +import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { completeTaskRunByRunId, createQueuedTaskRun, @@ -37,6 +38,16 @@ vi.mock("../../agents/harness/builtin-pi.js", () => ({ })); const baseCfg = baseCommandTestConfig; +const codexStatusModel: ModelDefinitionConfig = { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_050_000, + contextTokens: 1_000_000, + maxTokens: 128_000, +}; async function buildStatusReplyForTest(params: { sessionKey?: string; verbose?: boolean }) { const commandParams = buildCommandTestParams("/status", baseCfg); @@ -650,6 +661,52 @@ describe("buildStatusReply subagent summary", () => { ); }); + it("uses Codex OAuth context overrides for openai models running on the Codex harness", async () => { + registerStatusCodexHarness(); + + const text = await buildStatusText({ + cfg: { + ...baseCfg, + models: { + providers: { + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [codexStatusModel], + }, + }, + }, + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + }, + sessionEntry: { + sessionId: "sess-status-codex-context", + updatedAt: 0, + totalTokens: 25_000, + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "openai", + model: "gpt-5.5", + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "oauth", + activeModelAuthOverride: "oauth", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model: openai/gpt-5.5"); + expect(normalized).toContain("Context: 25k/1.0m"); + }); + it("uses workspace-scoped auth evidence in /status auth labels", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-status-auth-label-")); const workspaceDir = path.join(tempRoot, "workspace"); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 758430c6eb0..327434ebff1 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -286,6 +286,7 @@ function resolveModelSelectionForCommand(params: { async function persistModelDirectiveForTest(params: { command: string; profiles?: Record; + cfg?: OpenClawConfig; aliasIndex?: ModelAliasIndex; allowedModelKeys: string[]; sessionEntry?: SessionEntry; @@ -297,7 +298,7 @@ async function persistModelDirectiveForTest(params: { setAuthProfiles(params.profiles); } const directives = parseInlineDirectives(params.command); - const cfg = baseConfig(); + const cfg = params.cfg ?? baseConfig(); const sessionEntry = params.sessionEntry ?? createSessionEntry(); const persisted = await persistInlineDirectives({ directives, @@ -783,6 +784,39 @@ describe("/model chat UX", () => { expect(sessionEntry.agentRuntimeOverride).toBe("codex"); }); + it("uses Codex OAuth context config for persisted native Codex runtime directives", async () => { + const { persisted } = await persistModelDirectiveForTest({ + command: "/model openai/gpt-5.5 --runtime codex hello", + allowedModelKeys: ["openai/gpt-5.5"], + cfg: { + ...baseConfig(), + models: { + providers: { + "openai-codex": { + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [ + { + id: "gpt-5.5", + name: "GPT-5.5", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_050_000, + contextTokens: 1_000_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + } as OpenClawConfig, + }); + + expect(persisted.provider).toBe("openai"); + expect(persisted.model).toBe("gpt-5.5"); + expect(persisted.contextTokens).toBe(1_000_000); + }); + it("clears runtime overrides when the model directive asks for default runtime", async () => { const { sessionEntry } = await persistModelDirectiveForTest({ command: "/model openai/gpt-4o --runtime default hello", diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 49ee9ff2b29..c27de1e43d1 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -3,8 +3,6 @@ import { resolveDefaultAgentId, resolveSessionAgentId, } from "../../agents/agent-scope.js"; -import { resolveContextTokensForModel } from "../../agents/context.js"; -import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import { listLegacyRuntimeModelProviderAliases } from "../../agents/model-runtime-aliases.js"; import { normalizeProviderId, type ModelAliasIndex } from "../../agents/model-selection.js"; @@ -23,6 +21,7 @@ import { enqueueModeSwitchEvents, } from "./directive-handling.shared.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel } from "./directives.js"; +import { resolveContextTokens } from "./model-selection.js"; export type PersistedThinkingLevelRemap = { from: ThinkLevel; @@ -68,6 +67,29 @@ function resolveModelRuntimeOverride(params: { return { kind: "invalid", runtime: rawRuntime }; } +function resolveContextConfigProviderForRuntime(params: { + provider: string; + runtimeId?: string; +}): string { + const provider = normalizeProviderId(params.provider); + const runtimeId = normalizeProviderId(params.runtimeId ?? ""); + if (provider === "openai" && runtimeId === "codex") { + return "openai-codex"; + } + return params.provider; +} + +function resolveDirectiveRuntimeId(params: { + agentCfg: NonNullable["defaults"] | undefined; + sessionEntry?: SessionEntry; +}): string | undefined { + return ( + params.sessionEntry?.agentRuntimeOverride ?? + params.sessionEntry?.agentHarnessId ?? + params.agentCfg?.agentRuntime?.id + ); +} + export async function persistInlineDirectives(params: { directives: InlineDirectives; effectiveModelDirective?: string; @@ -342,13 +364,14 @@ export async function persistInlineDirectives(params: { provider, model, thinkingRemap, - contextTokens: - resolveContextTokensForModel({ - cfg, + contextTokens: resolveContextTokens({ + cfg, + agentCfg, + provider: resolveContextConfigProviderForRuntime({ provider, - model, - contextTokensOverride: agentCfg?.contextTokens, - allowAsyncLoad: false, - }) ?? DEFAULT_CONTEXT_TOKENS, + runtimeId: resolveDirectiveRuntimeId({ agentCfg, sessionEntry }), + }), + model, + }), }; } diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 080348b5ef9..f5d4a720cac 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -221,6 +221,32 @@ describe("resolveContextTokens", () => { expect(result).toBe(1_000_000); }); + + it("treats agent contextTokens as a cap, not an expansion beyond the model window", () => { + MODEL_CONTEXT_TOKEN_CACHE.set("openai/gpt-5.5", 272_000); + + const result = resolveContextTokens({ + cfg: {} as OpenClawConfig, + agentCfg: { contextTokens: 1_000_000 }, + provider: "openai", + model: "gpt-5.5", + }); + + expect(result).toBe(272_000); + }); + + it("allows agent contextTokens to lower a larger model window", () => { + MODEL_CONTEXT_TOKEN_CACHE.set("qwen/qwen3.6-plus", 1_000_000); + + const result = resolveContextTokens({ + cfg: {} as OpenClawConfig, + agentCfg: { contextTokens: 180_000 }, + provider: "qwen", + model: "qwen3.6-plus", + }); + + expect(result).toBe(180_000); + }); }); const makeEntry = (overrides: Partial = {}): SessionEntry => ({ diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 33b61a1c892..47da06773a9 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -328,14 +328,22 @@ export function resolveContextTokens(params: { provider: string; model: string; }): number { - return ( - params.agentCfg?.contextTokens ?? - resolveContextTokensForModel({ - cfg: params.cfg, - provider: params.provider, - model: params.model, - allowAsyncLoad: false, - }) ?? - DEFAULT_CONTEXT_TOKENS - ); + const modelContextTokens = resolveContextTokensForModel({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + allowAsyncLoad: false, + }); + const agentContextTokens = + typeof params.agentCfg?.contextTokens === "number" && params.agentCfg.contextTokens > 0 + ? Math.floor(params.agentCfg.contextTokens) + : undefined; + + if (agentContextTokens !== undefined) { + return modelContextTokens !== undefined + ? Math.min(agentContextTokens, modelContextTokens) + : agentContextTokens; + } + + return modelContextTokens ?? DEFAULT_CONTEXT_TOKENS; } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index e24edd95f3c..80707fdfbf4 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -1967,6 +1967,54 @@ describe("buildStatusMessage", () => { expect(normalized).not.toContain("Context: 25k/200k"); }); + it("does not let agent contextTokens inflate status above the model window", () => { + MODEL_CONTEXT_TOKEN_CACHE.set("openai/gpt-5.5", 272_000); + + const text = buildStatusMessage({ + agent: { + model: "openai/gpt-5.5", + contextTokens: 1_000_000, + }, + sessionEntry: { + sessionId: "sess-openai-codex-cap-context", + updatedAt: 0, + totalTokens: 25_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + modelAuth: "oauth", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Context: 25k/272k"); + expect(normalized).not.toContain("Context: 25k/1.0m"); + }); + + it("uses runtime context tokens to cap status when the sync cache is cold", () => { + const text = buildStatusMessage({ + agent: { + model: "openai/gpt-5.5", + contextTokens: 1_000_000, + }, + explicitConfiguredContextTokens: 1_000_000, + runtimeContextTokens: 272_000, + sessionEntry: { + sessionId: "sess-openai-codex-runtime-cap-context", + updatedAt: 0, + totalTokens: 25_000, + }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + modelAuth: "oauth", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Context: 25k/272k"); + expect(normalized).not.toContain("Context: 25k/1.0m"); + }); + it("does not synthesize a 32k fallback window when the active runtime model is unknown", () => { const text = buildStatusMessage({ config: { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 7568f12045d..9d06fc0322b 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -6,6 +6,7 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; import type WebSocket from "ws"; import { resetConfigRuntimeState } from "../config/config.js"; import type { GuardedFetchOptions } from "../infra/net/fetch-guard.js"; +import type { GatewayCronState } from "./server-cron.js"; import { connectOk, cronIsolatedRun, @@ -156,9 +157,7 @@ async function setupCronTestRun(params: { return { prevSkipCron, dir }; } -type DirectCronState = { - cron: { start: () => Promise; stop: () => void }; - storePath: string; +type DirectCronState = GatewayCronState & { getRuntimeConfig: () => import("../config/types.openclaw.js").OpenClawConfig; }; @@ -191,12 +190,13 @@ function createCronEventCollector() { timer: ReturnType; }> = []; const flush = (payload: Record) => { - for (const waiter of [...waiters]) { + for (let index = waiters.length - 1; index >= 0; index -= 1) { + const waiter = waiters[index]; if (!waiter.check(payload)) { continue; } clearTimeout(waiter.timer); - waiters.splice(waiters.indexOf(waiter), 1); + waiters.splice(index, 1); waiter.resolve(payload); } }; diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index 9187b30605c..cdaf072878c 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -153,7 +153,8 @@ async function mapWithConcurrency( concurrency: number, fn: (item: T) => Promise, ): Promise { - const results = new Array(items.length); + const results: U[] = []; + results.length = items.length; let nextIndex = 0; const workerCount = Math.min(concurrency, items.length); await Promise.all( @@ -161,7 +162,7 @@ async function mapWithConcurrency( while (nextIndex < items.length) { const index = nextIndex; nextIndex += 1; - results[index] = await fn(items[index]!); + results[index] = await fn(items[index]); } }), ); diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 0addfcb040e..4ba78c3cdb3 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -651,12 +651,17 @@ export function buildStatusMessage(args: StatusArgs): string { model: selectedModel, allowAsyncLoad: false, }); - const activeContextTokens = resolveContextTokensForModel({ - cfg: contextConfig, - ...(contextLookupProvider ? { provider: contextLookupProvider } : {}), - model: contextLookupModel, - allowAsyncLoad: false, - }); + const explicitRuntimeContextTokens = + typeof args.runtimeContextTokens === "number" && args.runtimeContextTokens > 0 + ? args.runtimeContextTokens + : undefined; + const activeContextTokens = + resolveContextTokensForModel({ + cfg: contextConfig, + ...(contextLookupProvider ? { provider: contextLookupProvider } : {}), + model: contextLookupModel, + allowAsyncLoad: false, + }) ?? explicitRuntimeContextTokens; const channelModelNote = resolveChannelModelNote({ config: args.config, entry, @@ -672,10 +677,6 @@ export function buildStatusMessage(args: StatusArgs): string { typeof args.agent?.contextTokens === "number" && args.agent.contextTokens > 0 ? args.agent.contextTokens : undefined; - const explicitRuntimeContextTokens = - typeof args.runtimeContextTokens === "number" && args.runtimeContextTokens > 0 - ? args.runtimeContextTokens - : undefined; const explicitConfiguredContextTokens = typeof args.explicitConfiguredContextTokens === "number" && args.explicitConfiguredContextTokens > 0 @@ -687,14 +688,18 @@ export function buildStatusMessage(args: StatusArgs): string { ? Math.min(explicitConfiguredContextTokens, activeContextTokens) : explicitConfiguredContextTokens : undefined; + const cappedAgentContextTokens = + typeof agentContextTokens === "number" + ? typeof activeContextTokens === "number" + ? Math.min(agentContextTokens, activeContextTokens) + : agentContextTokens + : undefined; const channelOverrideContextTokens = channelModelNote ? (explicitRuntimeContextTokens ?? cappedConfiguredContextTokens ?? (typeof activeContextTokens === "number" - ? typeof agentContextTokens === "number" - ? Math.min(agentContextTokens, activeContextTokens) - : activeContextTokens - : agentContextTokens)) + ? (cappedAgentContextTokens ?? activeContextTokens) + : cappedAgentContextTokens)) : undefined; // When a fallback model is active, the selected-model context limit that // callers keep on the agent config is often stale. Prefer an explicit runtime @@ -743,7 +748,11 @@ export function buildStatusMessage(args: StatusArgs): string { ...(contextLookupProvider ? { provider: contextLookupProvider } : {}), model: contextLookupModel, contextTokensOverride: - channelOverrideContextTokens ?? persistedContextTokens ?? agentContextTokens, + channelOverrideContextTokens ?? + persistedContextTokens ?? + cappedConfiguredContextTokens ?? + cappedAgentContextTokens ?? + explicitRuntimeContextTokens, fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, allowAsyncLoad: false, }) ?? DEFAULT_CONTEXT_TOKENS); diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 0c3bd1c3633..337a4fb261c 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -7,6 +7,7 @@ import { resolveSessionAgentId, resolveAgentModelFallbacksOverride, } from "../agents/agent-scope.js"; +import { resolveContextTokensForModel } from "../agents/context.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; import { resolveModelAuthLabel } from "../agents/model-auth-label.js"; import { @@ -81,6 +82,19 @@ function loadStatusQueueRuntime(): Promise 0 ? agentDefaults.contextTokens : undefined, + runtimeContextTokens, sessionEntry, sessionKey, parentSessionKey,