fix: preserve GPT-5.6 Max and Ultra through Codex (#126492)

* fix(agents): preserve GPT-5.6 thinking capabilities

Carry harness-owned effort metadata from the prepared catalog to the final executable model without leaking it across model or harness changes. Preserve exact GPT-5.6 registry metadata and persisted Ultra selections across route-scoped projections.

* fix(agents): resolve ambient catalog owner

* test(commands): mock thinking hydration policy

* fix(sessions): validate persisted thinking selections

* fix(commands): persist thinking selection provenance

* fix(agents): scope thinking hydration per fallback

* fix(sessions): hide fallback thinking provenance
This commit is contained in:
Peter Steinberger
2026-08-19 21:48:18 -07:00
committed by GitHub
parent 29f86119e0
commit efc33f882e
46 changed files with 1134 additions and 226 deletions
+2 -2
View File
@@ -1811,7 +1811,7 @@ src/agents/embedded-agent-runner/compaction-hooks.ts 1
src/agents/embedded-agent-runner/compaction-session-agent.ts 7
src/agents/embedded-agent-runner/context-engine-maintenance.ts 3
src/agents/embedded-agent-runner/delivery-evidence.ts 12
src/agents/embedded-agent-runner/direct-compaction-preparation.ts 3
src/agents/embedded-agent-runner/direct-compaction-preparation.ts 1
src/agents/embedded-agent-runner/empty-assistant-turn.ts 1
src/agents/embedded-agent-runner/extensions.ts 1
src/agents/embedded-agent-runner/extra-params.ts 12
@@ -2041,7 +2041,7 @@ src/agents/sessions/model-registry.ts 13
src/agents/sessions/model-resolver.ts 1
src/agents/sessions/package-manager.ts 8
src/agents/sessions/resolve-config-value.ts 1
src/agents/sessions/sdk.ts 4
src/agents/sessions/sdk.ts 3
src/agents/sessions/session-manager-branching.ts 1
src/agents/sessions/session-manager-codec.ts 7
src/agents/sessions/session-manager-core.ts 1
+46 -26
View File
@@ -1929,33 +1929,12 @@ describe("buildOpenAIProvider", () => {
});
});
it.each([
{
id: "gpt-5.6",
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
thinkingLevelMap: { off: "none", xhigh: "xhigh", max: "max" },
},
{
id: "gpt-5.6-sol",
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
thinkingLevelMap: { off: "none", xhigh: "xhigh", max: "max" },
},
{
id: "gpt-5.6-terra",
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 },
thinkingLevelMap: { off: "none", xhigh: "xhigh", max: "max" },
},
{
id: "gpt-5.6-luna",
cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
thinkingLevelMap: { off: "none", xhigh: "xhigh", max: "max" },
},
])("resolves $id locally with direct API metadata", ({ id, cost, thinkingLevelMap }) => {
it("synthesizes the gpt-5.6 alias from the nearest direct API template", () => {
const provider = buildOpenAIProvider();
const model = provider.resolveDynamicModel?.({
provider: "openai",
modelId: id,
modelId: "gpt-5.6",
modelRegistry: {
find: (_provider: string, templateId: string) =>
templateId === "gpt-5.5"
@@ -1977,18 +1956,59 @@ describe("buildOpenAIProvider", () => {
} as never);
expectFields(model, {
id,
id: "gpt-5.6",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
cost,
thinkingLevelMap,
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
thinkingLevelMap: { off: "none", xhigh: "xhigh", max: "max" },
});
});
it.each([
{
id: "gpt-5.6-sol",
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 },
},
{
id: "gpt-5.6-terra",
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 3.125 },
},
{
id: "gpt-5.6-luna",
cost: { input: 1, output: 6, cacheRead: 0.1, cacheWrite: 1.25 },
},
])("preserves exact registry metadata for $id", ({ id, cost }) => {
const provider = buildOpenAIProvider();
const exactModel = {
id,
name: id,
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text", "image"],
cost,
contextWindow: 1_050_000,
contextTokens: 272_000,
maxTokens: 128_000,
compat: { supportedReasoningEfforts: ["registry-exact"] },
};
const model = provider.resolveDynamicModel?.({
provider: "openai",
modelId: id,
modelRegistry: {
find: (_provider: string, templateId: string) => (templateId === id ? exactModel : null),
} as never,
} as never);
expect(model).toBe(exactModel);
});
it("resolves gpt-5.5-pro locally", () => {
const provider = buildOpenAIProvider();
+11
View File
@@ -875,6 +875,17 @@ const OPENAI_GPT_FORWARD_COMPAT_CASES = [
] satisfies Parameters<typeof resolveFamilyForwardCompatModel>[0]["cases"];
function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelContext) {
const modelId = normalizeLowercaseStringOrEmpty(ctx.modelId);
if (
modelId === OPENAI_GPT_56_SOL_MODEL_ID ||
modelId === OPENAI_GPT_56_TERRA_MODEL_ID ||
modelId === OPENAI_GPT_56_LUNA_MODEL_ID
) {
const exactModel = ctx.modelRegistry.find(PROVIDER_ID, ctx.modelId);
if (exactModel) {
return exactModel;
}
}
return resolveFamilyForwardCompatModel({
providerId: PROVIDER_ID,
ctx,
@@ -84,6 +84,8 @@ type ModelCatalogEntry = {
provider: string;
id: string;
name?: string;
api?: string;
baseUrl?: string;
reasoning?: boolean;
compat?: unknown;
};
@@ -112,8 +114,13 @@ export function isTestModelKeyAllowed(allowedKeys: ReadonlySet<string>, key: str
}
export function buildTestConfiguredModelCatalog(cfg?: unknown): ModelCatalogEntry[] {
const providers = (cfg as { models?: { providers?: Record<string, { models?: unknown[] }> } })
?.models?.providers;
const providers = (
cfg as {
models?: {
providers?: Record<string, { api?: unknown; baseUrl?: unknown; models?: unknown[] }>;
};
}
)?.models?.providers;
if (!providers) {
return [];
}
@@ -130,6 +137,18 @@ export function buildTestConfiguredModelCatalog(cfg?: unknown): ModelCatalogEntr
provider,
id,
name: typeof model.name === "string" ? model.name : id,
api:
typeof model.api === "string"
? model.api
: typeof entry.api === "string"
? entry.api
: undefined,
baseUrl:
typeof model.baseUrl === "string"
? model.baseUrl
: typeof entry.baseUrl === "string"
? entry.baseUrl
: undefined,
reasoning: typeof model.reasoning === "boolean" ? model.reasoning : undefined,
compat: model.compat,
};
@@ -113,7 +113,7 @@ const state = vi.hoisted(() => ({
isThinkingLevelSupportedMock: vi.fn((_args: unknown) => true),
resolveSupportedThinkingLevelMock: vi.fn(({ level }: { level?: string }) => level),
resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"),
loadManifestModelCatalogMock: vi.fn(() => []),
loadManifestModelCatalogMock: vi.fn((): ModelCatalogSnapshot["entries"] => []),
manifestMetadataSnapshot: { plugins: [] },
resolvePluginMetadataSnapshotMock: vi.fn(),
listSkillCommandsForWorkspaceMock: vi.fn((_params: unknown) => []),
@@ -2509,6 +2509,12 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
return entry?.thinkingLevel === "medium";
})?.[0] as { entry?: Record<string, unknown> } | undefined;
expect(touchWrite?.entry?.lastInteractionAt).toBeDefined();
expectRecordFields(touchWrite?.entry?.thinkingLevelSelection, {
provider: "anthropic",
model: "claude",
agentRuntime: "openclaw",
level: "medium",
});
expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1);
});
@@ -2709,6 +2715,84 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
expect(state.loadFullModelCatalogMock).not.toHaveBeenCalled();
});
it("keeps later provider capability metadata after hydrating a Codex primary", async () => {
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
"gmn/gpt-5.4": { agentRuntime: { id: "openclaw" } },
},
},
},
};
state.loadManifestModelCatalogMock.mockReturnValue([
{
provider: "openai",
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
reasoning: true,
compat: { supportedReasoningEfforts: ["max"] },
},
{
provider: "gmn",
id: "gpt-5.4",
name: "GPT 5.4 via GMN",
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
},
]);
state.loadProviderScopedThinkingCatalogMock.mockImplementation(async (params: unknown) => {
const { provider } = params as { provider?: string };
if (provider !== "openai") {
throw new Error(`unexpected scoped thinking hydration for ${provider}`);
}
return [
{
provider: "openai",
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
reasoning: true,
compat: { supportedReasoningEfforts: ["max", "ultra"] },
},
];
});
state.resolveThinkingDefaultMock.mockImplementation((args: unknown) => {
const { provider, catalog } = args as {
provider?: string;
catalog?: Array<{ provider: string; id: string }>;
};
if (provider === "gmn") {
expect(catalog).toContainEqual(expect.objectContaining({ provider: "gmn", id: "gpt-5.4" }));
return "xhigh";
}
return "ultra";
});
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
await params.run(params.provider, params.model);
const result = await params.run("gmn", "gpt-5.4");
return { result, provider: "gmn", model: "gpt-5.4", attempts: [] };
});
state.runAgentAttemptMock.mockImplementation(
async (params: { providerOverride: string; modelOverride: string }) =>
makeSuccessResult(params.providerOverride, params.modelOverride),
);
await runBasicAgentCommand();
expectRecordFields(mockCallArg(state.runAgentAttemptMock, 0), {
modelOverride: "gpt-5.6-sol",
resolvedThinkLevel: "ultra",
});
expectRecordFields(mockCallArg(state.runAgentAttemptMock, 1), {
providerOverride: "gmn",
modelOverride: "gpt-5.4",
resolvedThinkLevel: "xhigh",
});
expect(state.loadProviderScopedThinkingCatalogMock).toHaveBeenCalledTimes(1);
});
it("persists and clears current run delivery context for restart recovery", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
@@ -3722,6 +3806,75 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
expect(lifecycleFinishingCalls).toHaveLength(1);
});
it("forwards harness-augmented GPT-5.6 thinking capability to the attempt", async () => {
const modelId = "gpt-5.6-sol";
const modelKey = `openai/${modelId}`;
const providerReasoningEfforts = ["low", "medium", "high", "xhigh", "max"];
const harnessReasoningEfforts = [...providerReasoningEfforts, "ultra"];
state.runtimeConfigMock = {
agents: {
defaults: {
model: { primary: modelKey },
models: { [modelKey]: { agentRuntime: { id: "codex" } } },
},
},
models: {
providers: {
openai: {
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
models: [],
},
},
},
};
state.loadManifestModelCatalogMock.mockReturnValue([
{
provider: "openai",
id: modelId,
name: modelId,
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
compat: {
thinkingFormat: "openai",
supportedReasoningEfforts: providerReasoningEfforts,
},
},
]);
state.loadProviderScopedThinkingCatalogMock.mockResolvedValue([
{
provider: "openai",
id: modelId,
name: modelId,
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
reasoning: true,
compat: {
thinkingFormat: "openai",
supportedReasoningEfforts: harnessReasoningEfforts,
},
},
]);
setupSuccessfulAttempt("openai", modelId);
await agentCommand({ message: "hello", to: "+1234567890", thinking: "ultra" });
expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
modelOverride: modelId,
resolvedThinkLevel: "ultra",
modelThinkingCapability: {
provider: "openai",
modelId,
agentRuntime: "codex",
compat: {
thinkingFormat: "openai",
supportedReasoningEfforts: harnessReasoningEfforts,
},
},
});
});
it.each([
{
name: "validates explicit thinking against configured model compat without an allowlist",
@@ -3868,25 +3868,40 @@ describe("embedded attempt harness pinning", () => {
agentRuntimeOverride: "openclaw",
agentHarnessId: "codex",
});
const modelThinkingCapability = {
provider: "openai",
modelId: "gpt-5.6-sol",
agentRuntime: "openclaw",
route: {
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
},
compat: {
thinkingFormat: "openai",
supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
},
} as const;
runEmbeddedAgentMock.mockResolvedValueOnce({
meta: { durationMs: 1 },
} satisfies EmbeddedAgentRunResult);
await runHarnessAttempt({
modelOverride: "gpt-5.6-luna",
modelOverride: "gpt-5.6-sol",
modelThinkingCapability,
sessionEntry,
agentHarnessRuntimeOverride: "openclaw",
resolvedThinkLevel: "ultra",
resolvedThinkLevel: "max",
runId: "run-explicit-openclaw-runtime",
sessionHasHistory: true,
});
expectMockArgFields(runEmbeddedAgentMock, {
provider: "openai",
model: "gpt-5.6-luna",
model: "gpt-5.6-sol",
modelThinkingCapability,
agentHarnessId: "openclaw",
agentHarnessRuntimeOverride: "openclaw",
thinkLevel: "ultra",
thinkLevel: "max",
});
});
+2
View File
@@ -492,6 +492,7 @@ export function runAgentAttempt(params: {
providerOverride: string;
modelOverride: string;
modelHasVision?: boolean;
modelThinkingCapability?: RunEmbeddedAgentInternalParams["modelThinkingCapability"];
configuredAuthProfileId?: string;
originalProvider: string;
cfg: OpenClawConfig;
@@ -1182,6 +1183,7 @@ export function runAgentAttempt(params: {
provider: embeddedAgentProvider,
model: params.modelOverride,
modelHasVision: params.modelHasVision,
modelThinkingCapability: params.modelThinkingCapability,
modelFallbacksOverride: params.modelFallbacksOverride,
authProfileId,
authProfileIdSource: authProfileId ? harnessAuthSelection.authProfileIdSource : undefined,
+8 -1
View File
@@ -6,7 +6,7 @@ import {
type ThinkLevel,
} from "../../auto-reply/thinking.js";
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { requireActivePluginRegistry } from "../../plugins/runtime.js";
@@ -55,6 +55,7 @@ import {
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../openai-routing.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import { resolveSessionRuntimeOverrideForProvider } from "../session-runtime-compat.js";
import { updateSessionThinkingLevelSelection } from "../session-thinking-level-selection.js";
import {
hasResolvedThinkingCatalogEntry,
normalizeThinkingCatalogProviders,
@@ -609,6 +610,12 @@ export async function resolveEmbeddedModelSelection(params: {
lastInteractionAt: now,
thinkingLevel: params.thinkOverride,
};
updateSessionThinkingLevelSelection(next, {
provider,
model,
agentRuntime: thinkingRuntime,
level: params.thinkOverride,
});
sessionEntry =
(await persistAgentSession({
sessionStore: params.sessionStore,
+11 -17
View File
@@ -30,7 +30,7 @@ import { resolveFastModeState } from "../fast-mode.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.js";
import { prepareInternalSessionEffectsSession } from "../internal-session-effects.js";
import { LiveSessionModelSwitchError } from "../live-model-switch.js";
import { findModelInCatalog, modelSupportsInput } from "../model-catalog-lookup.js";
import { prepareModelRunCapabilities } from "../model-catalog-lookup.js";
import { modelKey, resolveThinkingDefault } from "../model-selection.js";
import { resolveConfiguredThinkingDefault } from "../model-thinking-default.js";
import { createModelVisibilityPolicy } from "../model-visibility-policy.js";
@@ -43,10 +43,10 @@ import {
import { resolveSessionRuntimeOverrideForProvider } from "../session-runtime-compat.js";
import { measureAgentStartup } from "../startup-timing.js";
import {
hasResolvedThinkingCatalogEntry,
normalizeThinkingCatalogProviders,
resolveCandidateThinkingLevel,
resolveEffectiveAgentRuntime,
needsThinkHydration,
} from "../thinking-runtime.js";
import {
createAgentAttemptLifecycleCallbacks,
@@ -120,8 +120,7 @@ export async function runEmbeddedAgentAttempt(params: {
storedModelOverrideSource,
effectiveTurnThinkLevel,
} = params.modelSelection;
let thinkingCatalog = params.modelSelection.thinkingCatalog;
let attemptedThinkingCatalogHydration = false;
const thinkingCatalog = params.modelSelection.thinkingCatalog;
let sessionEntry = params.sessionEntry;
let lifecycleGeneration = params.lifecycleGeneration;
@@ -388,17 +387,12 @@ export async function runEmbeddedAgentAttempt(params: {
provider: providerOverride,
model: modelOverride,
});
let candidateThinkingCatalog = thinkingCatalog;
if (
pluginsEnabled &&
candidateConfiguredThinkLevel !== "off" &&
!attemptedThinkingCatalogHydration &&
!hasResolvedThinkingCatalogEntry({
catalog: thinkingCatalog,
provider: providerOverride,
model: modelOverride,
})
needsThinkHydration(thinkingCatalog, providerOverride, modelOverride, candidateRuntime)
) {
attemptedThinkingCatalogHydration = true;
const { loadProviderScopedThinkingCatalog } =
await import("../model-catalog.runtime.js");
const runtimeCatalog = normalizeThinkingCatalogProviders(
@@ -421,7 +415,7 @@ export async function runEmbeddedAgentAttempt(params: {
...modelManifestContext,
}).allowedCatalog;
if (allowedRuntimeCatalog.length > 0) {
thinkingCatalog = allowedRuntimeCatalog;
candidateThinkingCatalog = allowedRuntimeCatalog;
}
}
const candidateRequestedThinkLevel =
@@ -430,7 +424,7 @@ export async function runEmbeddedAgentAttempt(params: {
cfg,
provider: providerOverride,
model: modelOverride,
catalog: thinkingCatalog,
catalog: candidateThinkingCatalog,
agentRuntime: candidateRuntime,
});
const candidateThinkLevel =
@@ -439,7 +433,7 @@ export async function runEmbeddedAgentAttempt(params: {
provider: providerOverride,
modelId: modelOverride,
level: candidateRequestedThinkLevel,
catalog: thinkingCatalog,
catalog: candidateThinkingCatalog,
agentId: sessionAgentId,
sessionKey,
sessionEntry: attemptSessionEntry,
@@ -450,9 +444,9 @@ export async function runEmbeddedAgentAttempt(params: {
preparedRunAdmission: params.preparedRunAdmission,
providerOverride,
modelOverride,
modelHasVision: modelSupportsInput(
findModelInCatalog(thinkingCatalog ?? [], providerOverride, modelOverride),
"image",
...prepareModelRunCapabilities(
[candidateThinkingCatalog, params.prepared.configuredThinkingCatalog],
[providerOverride, modelOverride, candidateRuntime],
),
configuredAuthProfileId,
modelFallbacksOverride: effectiveFallbacksOverride,
@@ -17,6 +17,7 @@ import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js";
import { describeFailoverError } from "../failover-error.js";
import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js";
import { MissingProviderAuthError } from "../model-auth.js";
import { projectModelThinkingCompat } from "../model-catalog-lookup.js";
import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js";
import { applyPreparedRuntimeAuthToModel } from "../provider-request-config.js";
import {
@@ -285,22 +286,7 @@ export async function prepareDirectCompactionAttempt(
const reason = formatErrorMessage(err);
return { ok: false as const, result: fail(reason, err) };
}
const runtimeCompat =
runtimeModel.compat && typeof runtimeModel.compat === "object"
? (runtimeModel.compat as Record<string, unknown>)
: undefined;
const thinkingFormat =
typeof runtimeCompat?.thinkingFormat === "string" ? runtimeCompat.thinkingFormat : undefined;
const supportedReasoningEfforts =
runtimeCompat?.supportedReasoningEfforts === null ||
(Array.isArray(runtimeCompat?.supportedReasoningEfforts) &&
runtimeCompat.supportedReasoningEfforts.every((effort) => typeof effort === "string"))
? (runtimeCompat.supportedReasoningEfforts as readonly string[] | null)
: undefined;
const thinkingCompat =
thinkingFormat !== undefined || supportedReasoningEfforts !== undefined
? { thinkingFormat, supportedReasoningEfforts }
: undefined;
const thinkingCompat = projectModelThinkingCompat(runtimeModel.compat);
const thinkingCatalogEntry = {
provider: runtimeModel.provider,
id: runtimeModel.id,
@@ -1,8 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
prepareModelRunCapabilities,
resolvePreparedModelThinkingCompat,
} from "../model-catalog-lookup.js";
import type { ModelCatalogEntry } from "../model-catalog.types.js";
import { resolveInitialEmbeddedRunModel } from "./run/runtime-resolution.js";
const STATIC_MODEL_ID = "claude-haiku-4-5";
const PROVIDER = "anthropic";
const resolveHookModelSelectionMock = vi.hoisted(() =>
vi.fn(async ({ provider, modelId }: { provider: string; modelId: string }) => ({
provider,
modelId,
})),
);
const emptyModelRegistry = {
find: vi.fn((_provider: string, _modelId: string) => null),
@@ -20,6 +31,7 @@ const staticCatalogModel = {
input: ["text", "image"],
contextWindow: 200_000,
maxTokens: 64_000,
compat: { supportsLongCacheRetention: false },
};
const resolveModelAsyncMock = vi.fn(
@@ -39,7 +51,10 @@ const resolveModelAsyncMock = vi.fn(
modelRegistry: options?.modelRegistry ?? emptyModelRegistry,
};
if (options?.allowBundledStaticCatalogFallback) {
return { ...stores, model: staticCatalogModel };
return {
...stores,
model: { ...staticCatalogModel, provider, id: modelId, name: modelId },
};
}
return {
...stores,
@@ -77,12 +92,7 @@ vi.mock("../prepared-model-runtime.js", () => ({
vi.mock("./run/setup.js", () => ({
buildBeforeModelResolveAttachments: vi.fn(() => []),
createNativeModelOwnedRuntimeModel: vi.fn(),
resolveHookModelSelection: vi.fn(
async ({ provider, modelId }: { provider: string; modelId: string }) => ({
provider,
modelId,
}),
),
resolveHookModelSelection: resolveHookModelSelectionMock,
resolveNativeModelOwnedHarnessId: vi.fn(() => undefined),
}));
@@ -152,7 +162,26 @@ vi.mock("./logger.js", () => ({
const { resolveEmbeddedRunModelSetup } = await import("./run/model-setup.js");
const { prepareDirectCompactionAttempt } = await import("./direct-compaction-preparation.js");
function createPreparedModelRuntime(config: Record<string, unknown>) {
return {
agentDir: "/tmp/agents/main/agent",
config,
workspaceDir: "/tmp/openclaw-model-resolution",
pluginRegistry: {},
configuredRuntimeModels: [],
inlineProviderModels: [],
createStores: () => ({ authStorage, modelRegistry: emptyModelRegistry }),
};
}
describe("embedded model resolution consistency", () => {
beforeEach(() => {
resolveHookModelSelectionMock.mockReset().mockImplementation(async ({ provider, modelId }) => ({
provider,
modelId,
}));
});
it("resolves an explicit alias configured only on the selected agent", () => {
const config = {
agents: {
@@ -186,15 +215,7 @@ describe("embedded model resolution consistency", () => {
},
};
const target = resolveInitialEmbeddedRunModel({ config });
const preparedModelRuntime = {
agentDir: "/tmp/agents/main/agent",
config,
workspaceDir: "/tmp/openclaw-model-resolution",
pluginRegistry: {},
configuredRuntimeModels: [],
inlineProviderModels: [],
createStores: () => ({ authStorage, modelRegistry: emptyModelRegistry }),
};
const preparedModelRuntime = createPreparedModelRuntime(config);
const chat = await resolveEmbeddedRunModelSetup({
runParams: {
@@ -235,4 +256,115 @@ describe("embedded model resolution consistency", () => {
id: STATIC_MODEL_ID,
});
});
it("resolves route-bound thinking compatibility for the final model", () => {
const capability = {
provider: PROVIDER,
modelId: STATIC_MODEL_ID,
agentRuntime: "openclaw",
route: { api: staticCatalogModel.api, baseUrl: staticCatalogModel.baseUrl },
compat: {
supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
},
} as const;
expect(
resolvePreparedModelThinkingCompat({
capability,
model: staticCatalogModel,
agentRuntime: "openclaw",
}),
).toEqual(capability.compat);
});
it("keeps configured provider routes off harness-scoped thinking capability", () => {
const compat = { supportedReasoningEfforts: ["max", "ultra"] };
const preparedCatalog: ModelCatalogEntry[] = [
{
provider: PROVIDER,
id: STATIC_MODEL_ID,
name: STATIC_MODEL_ID,
api: "openai-chatgpt-responses",
baseUrl: "https://chatgpt.example/codex",
compat,
},
];
const configuredCatalog: ModelCatalogEntry[] = [
{
provider: PROVIDER,
id: STATIC_MODEL_ID,
name: STATIC_MODEL_ID,
api: "anthropic-messages",
baseUrl: staticCatalogModel.baseUrl,
},
];
expect(
prepareModelRunCapabilities(
[preparedCatalog, configuredCatalog],
[PROVIDER, STATIC_MODEL_ID, "codex"],
).modelThinkingCapability,
).toEqual({
provider: PROVIDER,
modelId: STATIC_MODEL_ID,
agentRuntime: "codex",
compat,
});
});
it("resolves harness-scoped thinking compatibility across prepared auth routes", () => {
const compat = { supportedReasoningEfforts: ["max", "ultra"] } as const;
expect(
resolvePreparedModelThinkingCompat({
capability: {
provider: PROVIDER,
modelId: STATIC_MODEL_ID,
agentRuntime: "codex",
compat,
},
model: {
...staticCatalogModel,
api: "openai-responses",
baseUrl: "https://api.example/v1",
},
agentRuntime: "codex",
}),
).toEqual(compat);
});
it.each([
{
name: "model",
model: { ...staticCatalogModel, id: "hook-rerouted-model" },
agentRuntime: "openclaw",
},
{
name: "physical route",
model: { ...staticCatalogModel, baseUrl: "https://other.example/v1" },
agentRuntime: "openclaw",
},
{
name: "agent harness",
model: staticCatalogModel,
agentRuntime: "codex",
},
])(
"does not apply prepared thinking compatibility to a different $name",
({ model, agentRuntime }) => {
const result = resolvePreparedModelThinkingCompat({
capability: {
provider: PROVIDER,
modelId: STATIC_MODEL_ID,
agentRuntime: "openclaw",
route: { api: staticCatalogModel.api, baseUrl: staticCatalogModel.baseUrl },
compat: { supportedReasoningEfforts: ["max"] },
},
model,
agentRuntime,
});
expect(result).toBeUndefined();
},
);
});
@@ -50,6 +50,7 @@ import type { ContextEngineLogicalTurnLease } from "../../harness/context-engine
import type { ContextEngineTurnAttemptFacts } from "../../harness/context-engine-turn-attempt.js";
import type { ExpectedAgentHarnessRuntimeArtifact } from "../../harness/runtime-artifact.types.js";
import type { AgentInternalEvent } from "../../internal-events.js";
import type { PreparedModelThinkingCapability } from "../../model-catalog-lookup.js";
import type { AgentRunSessionTarget } from "../../run-session-target.js";
import type { AgentMessage } from "../../runtime/index.js";
import type { ScheduledToolPolicyContext } from "../../scheduled-tool-policy.js";
@@ -247,6 +248,8 @@ export type RunEmbeddedAgentParams = {
model?: string;
/** Vision capability resolved by the run owner from its prepared model catalog. */
modelHasVision?: boolean;
/** Route-bound thinking capability resolved from the selected prepared catalog row. */
modelThinkingCapability?: PreparedModelThinkingCapability;
/** Effective model fallback chain for this session attempt. Undefined uses config defaults. */
modelFallbacksOverride?: string[];
/** Session-pinned embedded harness id. Prevents runtime hot-switching. */
@@ -5,6 +5,7 @@ import { resolvePreparedRunAdmission } from "../../admitted-run-context.js";
import type { AuthProfileStore } from "../../auth-profiles.js";
import { isProfileInCooldown } from "../../auth-profiles.js";
import type { ResolvedProviderAuth } from "../../model-auth.js";
import { resolvePreparedModelThinkingCompat } from "../../model-catalog-lookup.js";
import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js";
import { resolveProviderEndpoint } from "../../provider-attribution.js";
import { getModelProviderRequestRouteFacts } from "../../provider-request-config.js";
@@ -92,6 +93,7 @@ export async function prepareEmbeddedRunRuntime(input: {
let agentHarness = modelSetup.agentHarness;
let pluginHarnessOwnsTransport = modelSetup.pluginHarnessOwnsTransport;
let runtimeModel = model;
let preparedThinkingCapabilityReady = false;
const resolveEffectiveModel = (candidate: typeof runtimeModel) =>
resolveEmbeddedRunEffectiveModel({
runParams: params,
@@ -113,9 +115,23 @@ export async function prepareEmbeddedRunRuntime(input: {
let effectiveModel = initialResolvedRuntimeModel.effectiveModel;
const applyResolvedRuntimeModel = (
candidate: typeof runtimeModel,
resolved = resolveEffectiveModel(candidate),
resolvedCandidate?: ReturnType<typeof resolveEffectiveModel>,
) => {
runtimeModel = candidate;
const preparedThinkingCompat = preparedThinkingCapabilityReady
? resolvePreparedModelThinkingCompat({
capability: params.modelThinkingCapability,
model: candidate,
agentRuntime: agentHarness.id,
})
: undefined;
const resolvedModel = preparedThinkingCompat
? { ...candidate, compat: { ...candidate.compat, ...preparedThinkingCompat } }
: candidate;
const resolved =
resolvedModel === candidate && resolvedCandidate
? resolvedCandidate
: resolveEffectiveModel(resolvedModel);
runtimeModel = resolvedModel;
effectiveModel = resolved.effectiveModel;
contextTokenBudget = resolved.contextTokenBudget;
authoredContextTokenCap = resolved.authoredContextTokenCap;
@@ -190,6 +206,8 @@ export async function prepareEmbeddedRunRuntime(input: {
preparedAuthAttempts,
} = preparedAuthPlan;
let { activePreparedAuthPlan } = preparedAuthPlan;
preparedThinkingCapabilityReady = true;
applyResolvedRuntimeModel(runtimeModel);
const genericCompactionRecoveryAllowed = !pluginHarnessOwnsTransport;
const profileCandidates = preparedAuthAttempts.map((attempt) => attempt.profileId);
const forwardedPluginHarnessProfileId = pluginHarnessOwnsTransport
+15 -3
View File
@@ -18,7 +18,19 @@ const cfg = {
} as OpenClawConfig;
const snapshot: ModelCatalogSnapshot = {
entries: [],
entries: [
{
provider: "openai",
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol (API)",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
compat: {
supportsReasoningEffort: true,
supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
},
},
],
routeVariants: [
{
provider: "openai",
@@ -40,7 +52,7 @@ const snapshot: ModelCatalogSnapshot = {
params: { providerFact: "kept", codexAppServerRuntimeModel: "stale-runtime" },
compat: {
supportsReasoningEffort: true,
supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
supportsTools: false,
},
},
@@ -103,7 +115,7 @@ describe("agent harness model catalog", () => {
params: { codexAppServerRuntimeModel: "gpt-5.6-sol-runtime" },
compat: {
supportsReasoningEffort: true,
supportedReasoningEfforts: ["high"],
supportedReasoningEfforts: ["high", "ultra"],
supportsTools: true,
},
},
+22 -10
View File
@@ -60,11 +60,14 @@ function mergeHarnessCompat(
if (observed?.supportedReasoningEfforts?.length === 0) {
return { ...compat, supportsReasoningEffort: false, supportedReasoningEfforts: [] };
}
const efforts = provider?.supportedReasoningEfforts?.length
? provider.supportedReasoningEfforts
: observed?.supportedReasoningEfforts;
return efforts
? { ...compat, supportsReasoningEffort: true, supportedReasoningEfforts: [...efforts] }
const efforts = [
...new Set([
...(provider?.supportedReasoningEfforts ?? []),
...(observed?.supportedReasoningEfforts ?? []),
]),
];
return efforts.length > 0
? { ...compat, supportsReasoningEffort: true, supportedReasoningEfforts: efforts }
: compat;
}
@@ -72,16 +75,25 @@ function enrichHarnessRows(
rows: readonly ModelCatalogEntry[],
snapshot: ModelCatalogSnapshot,
): ModelCatalogEntry[] {
const donors = new Map<string, ModelCatalogEntry>();
const routeDonors = new Map<string, ModelCatalogEntry>();
const identityDonors = new Map<string, ModelCatalogEntry>();
// First donor wins: live snapshot entries take precedence over static rows.
for (const donor of [...snapshot.entries, ...(snapshot.staticEntries ?? [])]) {
const key = resolveModelCatalogIdentityKey(donor);
if (!donors.has(key)) {
donors.set(key, donor);
const routeKey = routeVariantKey(donor);
const identityKey = resolveModelCatalogIdentityKey(donor);
if (!routeDonors.has(routeKey)) {
routeDonors.set(routeKey, donor);
}
if (!identityDonors.has(identityKey)) {
identityDonors.set(identityKey, donor);
}
}
return rows.map((entry) => {
const donor = donors.get(resolveModelCatalogIdentityKey(entry));
const donor =
routeDonors.get(routeVariantKey(entry)) ??
(entry.api === undefined && entry.baseUrl === undefined
? identityDonors.get(resolveModelCatalogIdentityKey(entry))
: undefined);
if (!donor) {
return entry;
}
+105
View File
@@ -2,11 +2,116 @@
* Looks up model catalog entries and input capability support.
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { isModelThinkingFormat, type ModelCompatConfig } from "../config/types.models.js";
import type { Model } from "../llm/types.js";
import type { ModelCatalogEntry, ModelInputType } from "./model-catalog.types.js";
import { modelTransportRoutesMatch } from "./model-compat-catalog.js";
import { canonicalizeProviderModelId } from "./provider-model-route.js";
type ModelThinkingCompat = {
thinkingFormat?: ModelCompatConfig["thinkingFormat"];
supportedReasoningEfforts?: readonly string[] | null;
};
export type PreparedModelThinkingCapability = Readonly<{
provider: string;
modelId: string;
agentRuntime: string;
/** Present only when the capability came from a physical provider route. */
route?: Readonly<{ api: string; baseUrl: string }>;
compat: ModelThinkingCompat;
}>;
/** Projects only thinking policy fields from broader model compatibility metadata. */
export function projectModelThinkingCompat(compat: unknown): ModelThinkingCompat | undefined {
const record = asOptionalRecord(compat);
if (!record) {
return undefined;
}
const projected: ModelThinkingCompat = {};
if (typeof record.thinkingFormat === "string" && isModelThinkingFormat(record.thinkingFormat)) {
projected.thinkingFormat = record.thinkingFormat;
}
if (record.supportedReasoningEfforts === null) {
projected.supportedReasoningEfforts = null;
} else if (
Array.isArray(record.supportedReasoningEfforts) &&
record.supportedReasoningEfforts.every((effort) => typeof effort === "string")
) {
projected.supportedReasoningEfforts = [...record.supportedReasoningEfforts];
}
return Object.keys(projected).length > 0 ? projected : undefined;
}
/** Freezes thinking capability from the selected prepared catalog row. */
function prepareModelThinkingCapability(params: {
entry: ModelCatalogEntry | undefined;
route?: Pick<ModelCatalogEntry, "api" | "baseUrl">;
agentRuntime: string;
}): PreparedModelThinkingCapability | undefined {
const compat = projectModelThinkingCompat(params.entry?.compat);
const provider = normalizeProviderId(params.entry?.provider ?? "");
const modelId = normalizeOptionalString(params.entry?.id);
const agentRuntime = normalizeLowercaseStringOrEmpty(params.agentRuntime);
if (!compat || !provider || !modelId || !agentRuntime) {
return undefined;
}
const routeSource = params.route ?? (agentRuntime === "openclaw" ? params.entry : undefined);
const api = normalizeOptionalString(routeSource?.api);
const baseUrl = normalizeOptionalString(routeSource?.baseUrl);
if (agentRuntime === "openclaw" && (!api || !baseUrl)) {
return undefined;
}
return {
provider,
modelId,
agentRuntime,
...(api && baseUrl ? { route: { api, baseUrl } } : {}),
compat,
};
}
/** Resolves prepared thinking metadata only for the exact final model route and harness. */
export function resolvePreparedModelThinkingCompat(params: {
capability?: PreparedModelThinkingCapability;
model: Pick<Model, "provider" | "id" | "api" | "baseUrl">;
agentRuntime: string;
}): ModelThinkingCompat | undefined {
const capability = params.capability;
if (!capability) {
return undefined;
}
const runtimeModelId = canonicalizeProviderModelId(capability.provider, params.model.id);
const preparedModelId = canonicalizeProviderModelId(capability.provider, capability.modelId);
return normalizeProviderId(params.model.provider) === capability.provider &&
runtimeModelId === preparedModelId &&
normalizeLowercaseStringOrEmpty(params.agentRuntime) === capability.agentRuntime &&
(!capability.route || modelTransportRoutesMatch(params.model, capability.route))
? capability.compat
: undefined;
}
/** Projects the prepared capabilities needed by one selected run candidate. */
export function prepareModelRunCapabilities(
[catalog, configuredCatalog]: readonly [ModelCatalogEntry[] | undefined, ModelCatalogEntry[]],
[provider, modelId, agentRuntime]: readonly [string, string, string],
) {
const entry = findModelInCatalog(catalog ?? [], provider, modelId);
const configuredEntry = findModelInCatalog(configuredCatalog, provider, modelId);
return {
modelHasVision: modelSupportsInput(entry, "image"),
modelThinkingCapability: prepareModelThinkingCapability({
entry: entry ?? configuredEntry,
route: agentRuntime === "openclaw" ? (configuredEntry ?? entry) : undefined,
agentRuntime,
}),
};
}
/** Returns whether a catalog entry declares support for an input modality. */
export function modelSupportsInput(
@@ -15,6 +15,7 @@ const scopedLiveMock = vi.fn(
routeVariants: [],
}),
);
const publishedSnapshotMock = vi.fn((..._args: unknown[]) => undefined as unknown);
vi.mock("./model-catalog.js", () => ({
loadManifestModelCatalog: (...args: unknown[]) => manifestCatalogMock(...args),
@@ -24,6 +25,7 @@ vi.mock("./prepared-model-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./prepared-model-runtime.js")>();
return {
...actual,
getPreparedModelRuntimeSnapshot: (...args: unknown[]) => publishedSnapshotMock(...args),
// No published lifecycle owner: force the scoped read-only builders to run.
prepareModelRuntimeSnapshot: vi.fn(async (input: { agentDir: string }) => {
throw new actual.PreparedModelRuntimeOwnerNotPublishedError(
@@ -51,6 +53,45 @@ describe("loadProviderScopedThinkingCatalog", () => {
manifestCatalogMock.mockReturnValue([]);
scopedStaticMock.mockResolvedValue({ entries: [], routeVariants: [] });
scopedLiveMock.mockResolvedValue({ entries: [], routeVariants: [] });
publishedSnapshotMock.mockReturnValue(undefined);
});
it("prefers the published prepared generation over partial manifest compatibility", async () => {
manifestCatalogMock.mockReturnValue([
{
provider: "openai",
id: "gpt-5.6-sol",
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] },
},
]);
publishedSnapshotMock.mockImplementation((input: unknown) => ({
config: (input as { config: unknown }).config,
modelCatalog: {
entries: [
{
provider: "openai",
id: "gpt-5.6-sol",
reasoning: true,
compat: {
supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
},
},
],
routeVariants: [],
},
}));
const { loadProviderScopedThinkingCatalog } = await import("./prepared-model-catalog.js");
const catalog = await loadProviderScopedThinkingCatalog({
config: {},
provider: "openai",
model: "gpt-5.6-sol",
});
expect(catalog[0]?.compat?.supportedReasoningEfforts).toContain("ultra");
expect(scopedStaticMock).not.toHaveBeenCalled();
expect(scopedLiveMock).not.toHaveBeenCalled();
});
it("resolves manifest-backed models without any scoped catalog build", async () => {
+37 -15
View File
@@ -40,6 +40,7 @@ import {
hasResolvedThinkingCatalogEntry,
normalizeThinkingCatalogProviders,
} from "./thinking-runtime.js";
import { resolveDefaultAgentWorkspaceDir } from "./workspace.js";
export type LoadPreparedModelCatalogParams = {
agentId?: string;
@@ -350,8 +351,8 @@ async function loadScopedReadOnlyModelCatalog(
}
/**
* Turn-path capability reads (thinking levels and similar per-model facts) must stay off the
* full live catalog build: manifest metadata first, then a provider-scoped read-only catalog,
* Turn-path capability reads (thinking levels and similar per-model facts) must stay off a new
* full catalog build: reuse the published generation, then manifest/scoped read-only metadata,
* then scoped live discovery only for providers whose models exist solely at runtime.
*/
export async function loadProviderScopedThinkingCatalog(params: {
@@ -379,22 +380,43 @@ export async function loadProviderScopedThinkingCatalog(params: {
} satisfies LoadPreparedModelCatalogParams;
const entryResolved = (catalog: readonly ModelCatalogEntry[]) =>
hasResolvedThinkingCatalogEntry({ catalog, provider: params.provider, model: params.model });
const augmentHarnessCatalog = async (snapshot: ModelCatalogSnapshot) => {
const agentId = params.agentId ?? resolveAmbientOwnerAgentId(params.config);
const { augmentModelCatalogWithAgentHarness } = await import("./harness/model-catalog.js");
const augmented = await augmentModelCatalogWithAgentHarness({
cfg: params.config,
agentId,
agentDir: params.agentDir ?? resolveAgentDir(params.config, agentId),
workspaceDir:
params.workspaceDir ??
resolveAgentWorkspaceDir(params.config, agentId) ??
resolveDefaultAgentWorkspaceDir(),
defaultProvider: params.provider,
defaultModel: `${params.provider}/${params.model}`,
snapshot,
});
return normalizeThinkingCatalogProviders(augmented.entries);
};
const publishedCatalog = getPreparedModelCatalogSnapshot(scopedParams);
if (publishedCatalog && entryResolved(publishedCatalog.entries)) {
return await augmentHarnessCatalog(publishedCatalog);
}
if (entryResolved(manifestCatalog)) {
return manifestCatalog;
return await augmentHarnessCatalog({
entries: manifestCatalog,
routeVariants: manifestCatalog,
staticEntries: manifestCatalog,
});
}
const scopedStatic = normalizeThinkingCatalogProviders(
(await loadPreparedModelCatalogSnapshot(scopedParams)).entries,
);
if (entryResolved(scopedStatic)) {
return scopedStatic;
const scopedStatic = await loadPreparedModelCatalogSnapshot(scopedParams);
if (entryResolved(scopedStatic.entries)) {
return await augmentHarnessCatalog(scopedStatic);
}
return normalizeThinkingCatalogProviders(
(
await loadPreparedModelCatalogSnapshot({
...scopedParams,
scopedLiveProviderDiscovery: true,
})
).entries,
return await augmentHarnessCatalog(
await loadPreparedModelCatalogSnapshot({
...scopedParams,
scopedLiveProviderDiscovery: true,
}),
);
}
+4 -3
View File
@@ -6,7 +6,7 @@ import {
} from "../config/sessions/session-accessor.js";
import {
createAgentPatchedSessionModelFallback,
type AgentPatchedSessionModelFallback,
type InternalAgentPatchedSessionModelFallback,
} from "../config/sessions/session-model-fallback.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveFailoverReasonFromError } from "./failover-error.js";
@@ -34,7 +34,7 @@ async function reconcileAgentPatchedSessionModel(params: {
storePath?: string;
outcome: SessionModelRunOutcome;
expectedMarkerTs?: number;
validatedFallback?: AgentPatchedSessionModelFallback;
validatedFallback?: InternalAgentPatchedSessionModelFallback;
now?: number;
}): Promise<"cleared" | "promoted" | "reverted" | "kept" | "none"> {
const reason = params.outcome.success
@@ -97,6 +97,7 @@ async function reconcileAgentPatchedSessionModel(params: {
authProfileOverrideSource: marker.prevAuthProfileOverrideSource,
authProfileOverrideCompactionCount: marker.prevAuthProfileOverrideCompactionCount,
thinkingLevel: marker.prevThinkingLevel,
thinkingLevelSelection: marker.prevThinkingLevelSelection,
modelFallback: undefined,
liveModelSwitchPending: undefined,
};
@@ -139,7 +140,7 @@ export function createAgentPatchedSessionModelRunGuard(params: {
onError?: (error: unknown) => void;
}) {
let markerTs: number | undefined;
let validatedFallback: AgentPatchedSessionModelFallback | undefined;
let validatedFallback: InternalAgentPatchedSessionModelFallback | undefined;
if (params.sessionKey) {
try {
const entry = loadSessionEntry({
@@ -0,0 +1,67 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeThinkLevel } from "../auto-reply/thinking.shared.js";
import type { SessionThinkingLevelSelection } from "../config/sessions/thinking-level-selection.js";
import type { InternalSessionEntry } from "../config/sessions/types.js";
type ThinkingSelectionParams = {
provider: string;
model: string;
agentRuntime?: string | null;
level?: string | null;
};
function createSessionThinkingLevelSelection(
params: ThinkingSelectionParams,
): SessionThinkingLevelSelection | undefined {
const provider = normalizeProviderId(params.provider);
const model = normalizeLowercaseStringOrEmpty(params.model);
const agentRuntime = normalizeLowercaseStringOrEmpty(params.agentRuntime);
const level = normalizeThinkLevel(params.level);
return provider && model && agentRuntime && level
? { provider, model, agentRuntime, level }
: undefined;
}
export function readSessionThinkingLevelSelection(
entry: InternalSessionEntry | undefined,
): SessionThinkingLevelSelection | undefined {
const selection = entry?.thinkingLevelSelection;
return selection ? { ...selection } : undefined;
}
export function clearSessionThinkingLevelSelection(entry: InternalSessionEntry): void {
delete entry.thinkingLevelSelection;
}
/** Records the exact model and harness that validated a persisted thinking override. */
export function updateSessionThinkingLevelSelection(
entry: InternalSessionEntry,
params: ThinkingSelectionParams,
): void {
const selection = createSessionThinkingLevelSelection(params);
if (selection) {
entry.thinkingLevelSelection = selection;
} else {
delete entry.thinkingLevelSelection;
}
}
export function sessionThinkingLevelSelectionMatches(params: {
entry?: InternalSessionEntry;
provider: string;
model: string;
agentRuntime: string;
level: string;
}): boolean {
const actual = readSessionThinkingLevelSelection(params.entry);
const expected = createSessionThinkingLevelSelection(params);
return (
actual !== undefined &&
expected !== undefined &&
actual.provider === expected.provider &&
actual.model === expected.model &&
actual.agentRuntime === expected.agentRuntime &&
actual.level === expected.level
);
}
+3 -27
View File
@@ -6,15 +6,13 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { clampThinkingLevel } from "@openclaw/ai/internal/runtime";
import {
resolveThinkingDefaultForModel,
type ThinkingCatalogEntry,
} from "../../auto-reply/thinking.js";
import { resolveThinkingDefaultForModel } from "../../auto-reply/thinking.js";
import { createSessionEntryWithTranscript } from "../../config/sessions/session-accessor.js";
import { bindStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
import type { Message, Model } from "../../llm/types.js";
import { sanitizeCompactionReplayMessages } from "../compaction-replay.js";
import { getAgentDir } from "../config.js";
import { projectModelThinkingCompat } from "../model-catalog-lookup.js";
import {
Agent,
type AgentMessage,
@@ -53,28 +51,6 @@ import {
type ToolName,
} from "./tools/index.js";
type ThinkingCatalogCompat = NonNullable<ThinkingCatalogEntry["compat"]>;
function projectThinkingCatalogCompat(compat: Model["compat"]) {
if (!compat || typeof compat !== "object") {
return undefined;
}
const record = compat as Record<string, unknown>;
const projected: ThinkingCatalogCompat = {};
if (typeof record.thinkingFormat === "string") {
projected.thinkingFormat = record.thinkingFormat;
}
if (record.supportedReasoningEfforts === null) {
projected.supportedReasoningEfforts = null;
} else if (
Array.isArray(record.supportedReasoningEfforts) &&
record.supportedReasoningEfforts.every((effort) => typeof effort === "string")
) {
projected.supportedReasoningEfforts = record.supportedReasoningEfforts;
}
return Object.keys(projected).length > 0 ? projected : undefined;
}
export interface CreateAgentSessionOptions {
/** Working directory for project-local discovery. Default: process.cwd() */
cwd?: string;
@@ -373,7 +349,7 @@ async function createAgentSessionImpl(
// provider defaults (high, low, adaptive) fall back to DEFAULT_THINKING_LEVEL to avoid
// silent cost changes for DeepSeek, OpenRouter, xAI, and other providers.
const modelThinkingProvider = model?.api === "ollama" ? "ollama" : model?.provider;
const modelThinkingCompat = model ? projectThinkingCatalogCompat(model.compat) : undefined;
const modelThinkingCompat = model ? projectModelThinkingCompat(model.compat) : undefined;
const resolvedProviderDefault =
model && modelThinkingProvider
? resolveThinkingDefaultForModel({
+12
View File
@@ -30,6 +30,18 @@ export function hasResolvedThinkingCatalogEntry(params: {
return entry?.reasoning !== undefined;
}
/** Reuses prepared capability facts for plugin runtimes even when the manifest is partial. */
export function needsThinkHydration(
catalog: readonly ThinkingCatalogEntry[] | undefined,
provider: string,
model: string,
agentRuntime: string,
): boolean {
return (
agentRuntime !== "openclaw" || !hasResolvedThinkingCatalogEntry({ catalog, provider, model })
);
}
export function normalizeThinkingCatalogProviders<T extends ThinkingCatalogEntry>(
catalog: readonly T[],
): T[] {
@@ -8,6 +8,7 @@ import {
formatFastModeValue,
resolveFastModeState,
} from "../../agents/fast-mode.js";
import { updateSessionThinkingLevelSelection } from "../../agents/session-thinking-level-selection.js";
import { persistStickyModelSelectionBestEffort } from "../../agents/sticky-model-selection.js";
import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js";
@@ -452,6 +453,9 @@ export async function handleDirectiveOnly(
if (shouldRemapUnsupportedThinkLevel && !touchedSessionFields.includes("thinkingLevel")) {
touchedSessionFields.push("thinkingLevel");
}
if (directives.hasThinkDirective || modelSelection || shouldRemapUnsupportedThinkLevel) {
touchedSessionFields.push("thinkingLevelSelection");
}
// Validated, authorized directives have already named every field they can mutate.
const shouldPersistSessionEntry = touchedSessionFields.length > 0;
const fastModeChanged =
@@ -489,6 +493,14 @@ export async function handleDirectiveOnly(
const appliedRuntime = applyModelRuntimeDirective(sessionEntry, modelRuntimeResolution);
modelSelectionUpdated = applied.updated || appliedRuntime.updated;
}
if (directives.hasThinkDirective || modelSelection || shouldRemapUnsupportedThinkLevel) {
updateSessionThinkingLevelSelection(sessionEntry, {
provider: resolvedProvider,
model: resolvedModel,
agentRuntime: thinkingRuntime,
level: sessionEntry.thinkingLevel,
});
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
@@ -8,7 +8,7 @@ import {
sessionModelOverrideChangesApplied,
sessionSnapshotChangesApplied,
} from "../../config/sessions/session-snapshot-merge.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions/types.js";
import { SYSTEM_MARK, prefixSystemMessage } from "../../infra/system-message.js";
import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
@@ -1,5 +1,5 @@
// Atomic persistence for broad auto-reply session snapshots.
import type { SessionEntry } from "../../config/sessions.js";
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions.js";
import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js";
import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js";
import {
@@ -5,7 +5,7 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { InternalSessionEntry, SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { clearSessionStoreCacheForTest } from "../../config/sessions/store-writer-state.js";
import type { ModelAliasIndex } from "./model-selection-directive.js";
@@ -92,13 +92,23 @@ describe("applyResetModelOverride", () => {
});
});
it("selects a model hint and strips it from the body", async () => {
it("selects a model hint and clears stale thinking selection provenance", async () => {
const { sessionEntry, sessionCtx } = await applyResetFixture({
resetTriggered: true,
sessionEntry: {
thinkingLevel: "ultra",
thinkingLevelSelection: {
provider: "openai",
model: "gpt-5.6-sol",
agentRuntime: "codex",
level: "ultra",
},
} as Partial<InternalSessionEntry>,
});
expect(sessionEntry.providerOverride).toBe("minimax");
expect(sessionEntry.modelOverride).toBe("m2.7");
expect((sessionEntry as InternalSessionEntry).thinkingLevelSelection).toBeUndefined();
expect(sessionCtx.BodyStripped).toBe("summarize");
});
+4 -1
View File
@@ -7,8 +7,9 @@ import {
buildAllowedModelSetWithFallbacks,
isModelKeyAllowedBySet,
} from "../../agents/model-selection-shared.js";
import { clearSessionThinkingLevelSelection } from "../../agents/session-thinking-level-selection.js";
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { InternalSessionEntry as SessionEntry } from "../../config/sessions.js";
import { SessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js";
import {
adoptPersistedSessionSnapshot,
@@ -148,6 +149,8 @@ async function applySelectionToSession(params: {
params.defaultProvider,
selection,
});
// Reset model hints do not resolve thinking capability; the next owner must revalidate it.
clearSessionThinkingLevelSelection(nextSessionEntry);
let appliedEntry = nextSessionEntry;
let selectionApplied = true;
if (storePath) {
+5 -1
View File
@@ -9,6 +9,7 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { clearBootstrapSnapshotOnSessionBoundary } from "../../agents/bootstrap-cache.js";
import { clearAllCliSessions, getCliSessionBinding } from "../../agents/cli-session.js";
import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/registry.js";
import { readSessionThinkingLevelSelection } from "../../agents/session-thinking-level-selection.js";
import { cleanupBrowserSessionsForLifecycleEnd } from "../../browser-lifecycle-cleanup.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
@@ -51,6 +52,7 @@ import {
DEFAULT_RESET_TRIGGERS,
SESSION_TOTAL_TOKENS_VERSION,
type GroupKeyResolution,
type InternalSessionEntry,
type SessionEntry,
type SessionScope,
} from "../../config/sessions/types.js";
@@ -384,10 +386,12 @@ function selectSessionModelOverride(
};
}
function resolveReplySessionRolloverState(entry: SessionEntry): Partial<SessionEntry> {
function resolveReplySessionRolloverState(entry: SessionEntry): Partial<InternalSessionEntry> {
const preservedSelection = resolveResetPreservedSelection({ entry });
const thinkingLevelSelection = readSessionThinkingLevelSelection(entry);
return {
thinkingLevel: entry.thinkingLevel,
...(thinkingLevelSelection ? { thinkingLevelSelection: { ...thinkingLevelSelection } } : {}),
verboseLevel: entry.verboseLevel,
traceLevel: entry.traceLevel,
reasoningLevel: entry.reasoningLevel,
+13
View File
@@ -142,6 +142,19 @@ vi.mock("../agents/thinking-runtime.js", () => ({
entry.id === params.model &&
entry.reasoning !== undefined,
) ?? false,
needsThinkHydration: (
catalog: Array<{ id: string; provider: string; reasoning?: boolean }> | undefined,
provider: string,
model: string,
agentRuntime: string,
) =>
agentRuntime !== "openclaw" ||
!catalog?.some(
(entry) =>
entry.provider.toLowerCase() === provider.toLowerCase() &&
entry.id === model &&
entry.reasoning !== undefined,
),
normalizeThinkingCatalogProviders: <T extends { provider: string }>(catalog: T[]) =>
catalog.map((entry) => ({ ...entry, provider: entry.provider.toLowerCase() })),
resolveCandidateThinkingLevel: ({ level }: { level?: string }) => level,
@@ -1,3 +1,7 @@
import type {
AgentPatchedSessionModelFallback,
InternalAgentPatchedSessionModelFallback,
} from "./session-model-fallback.js";
import type { InternalSessionEntry, SessionEntry } from "./types.js";
export const SESSION_ENTRY_PRIVATE_CLEAR_PATCH = {
@@ -5,6 +9,7 @@ export const SESSION_ENTRY_PRIVATE_CLEAR_PATCH = {
lifecycleRunId: undefined,
mainRestartRecovery: undefined,
sessionDiffBaselineCapture: undefined,
thinkingLevelSelection: undefined,
} satisfies Partial<InternalSessionEntry>;
const PRIVATE_SESSION_ENTRY_KEYS = [
@@ -12,8 +17,19 @@ const PRIVATE_SESSION_ENTRY_KEYS = [
"lifecycleRunId",
"mainRestartRecovery",
"sessionDiffBaselineCapture",
"thinkingLevelSelection",
] as const satisfies readonly (keyof InternalSessionEntry)[];
function projectPublicModelFallback(
fallback: InternalAgentPatchedSessionModelFallback | undefined,
): AgentPatchedSessionModelFallback | undefined {
if (!fallback) {
return undefined;
}
const { prevThinkingLevelSelection: _privateSelection, ...publicFallback } = fallback;
return publicFallback;
}
function stripPrivateSessionEntryFields(entry: InternalSessionEntry): SessionEntry;
function stripPrivateSessionEntryFields(
entry: Partial<InternalSessionEntry>,
@@ -25,6 +41,12 @@ function stripPrivateSessionEntryFields(
for (const key of PRIVATE_SESSION_ENTRY_KEYS) {
delete projected[key];
}
const modelFallback = projectPublicModelFallback(entry.modelFallback);
if (modelFallback) {
projected.modelFallback = modelFallback;
} else {
delete projected.modelFallback;
}
return projected;
}
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { inheritSessionSelection, SessionLabelOwnerIndex } from "./session-entry-selection.js";
import type { SessionEntry } from "./types.js";
import type { InternalSessionEntry, SessionEntry } from "./types.js";
describe("inheritSessionSelection", () => {
it("inherits canonical user and automatic provenance without the old generation", () => {
@@ -9,10 +9,19 @@ describe("inheritSessionSelection", () => {
sessionId: "legacy-user",
updatedAt: 1,
authProfileOverride: "openai:work",
}),
thinkingLevel: "ultra",
thinkingLevelSelection: {
provider: "openai",
model: "gpt-5.6-sol",
agentRuntime: "codex",
level: "ultra",
},
} as InternalSessionEntry),
).toMatchObject({
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
thinkingLevel: "ultra",
thinkingLevelSelection: { model: "gpt-5.6-sol", level: "ultra" },
});
const automatic = inheritSessionSelection({
@@ -1,6 +1,6 @@
import { resolveSessionAuthProfileOverrideSource } from "./auth-profile-override-provenance.js";
import type { SessionPatchProjectionSnapshot } from "./session-accessor.types.js";
import type { SessionEntry } from "./types.js";
import type { InternalSessionEntry, SessionEntry } from "./types.js";
type SessionProjectionTarget = {
candidateKeys?: readonly string[];
@@ -57,11 +57,13 @@ export class SessionLabelOwnerIndex {
/** Carries only user/runtime selection into a new dashboard fork. */
export function inheritSessionSelection(
parentEntry: SessionEntry | undefined,
): Partial<SessionEntry> {
): Partial<InternalSessionEntry> {
if (!parentEntry) {
return {};
}
const authProfileOverrideSource = resolveSessionAuthProfileOverrideSource(parentEntry);
const internalParentEntry: InternalSessionEntry = parentEntry;
const thinkingLevelSelection = internalParentEntry.thinkingLevelSelection;
return {
...(parentEntry.providerOverride ? { providerOverride: parentEntry.providerOverride } : {}),
...(parentEntry.modelOverride ? { modelOverride: parentEntry.modelOverride } : {}),
@@ -75,6 +77,7 @@ export function inheritSessionSelection(
? { agentRuntimeOverride: parentEntry.agentRuntimeOverride }
: {}),
...(parentEntry.thinkingLevel ? { thinkingLevel: parentEntry.thinkingLevel } : {}),
...(thinkingLevelSelection ? { thinkingLevelSelection: { ...thinkingLevelSelection } } : {}),
...(parentEntry.fastMode !== undefined ? { fastMode: parentEntry.fastMode } : {}),
...(parentEntry.toolOverrides ? { toolOverrides: parentEntry.toolOverrides } : {}),
...(parentEntry.verboseLevel ? { verboseLevel: parentEntry.verboseLevel } : {}),
+11 -1
View File
@@ -1,3 +1,5 @@
import type { SessionThinkingLevelSelection } from "./thinking-level-selection.js";
export type AgentPatchedSessionModelFallback = {
prevModel: string;
prevProvider: string;
@@ -16,6 +18,10 @@ export type AgentPatchedSessionModelFallback = {
source: "agent-patch";
};
export type InternalAgentPatchedSessionModelFallback = AgentPatchedSessionModelFallback & {
prevThinkingLevelSelection?: SessionThinkingLevelSelection;
};
export function createAgentPatchedSessionModelFallback(params: {
model: string;
provider: string;
@@ -30,9 +36,10 @@ export function createAgentPatchedSessionModelFallback(params: {
authProfileOverrideSource?: "auto" | "user";
authProfileOverrideCompactionCount?: number;
thinkingLevel?: string;
thinkingLevelSelection?: SessionThinkingLevelSelection;
};
ts: number;
}): AgentPatchedSessionModelFallback {
}): InternalAgentPatchedSessionModelFallback {
const { entry } = params;
return {
prevModel: params.model,
@@ -57,6 +64,9 @@ export function createAgentPatchedSessionModelFallback(params: {
? { prevAuthProfileOverrideCompactionCount: entry.authProfileOverrideCompactionCount }
: {}),
...(entry.thinkingLevel ? { prevThinkingLevel: entry.thinkingLevel } : {}),
...(entry.thinkingLevelSelection
? { prevThinkingLevelSelection: { ...entry.thinkingLevelSelection } }
: {}),
ts: params.ts,
source: "agent-patch",
};
@@ -14,6 +14,7 @@ export const SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS = [
"authProfileOverride",
"authProfileOverrideSource",
"authProfileOverrideCompactionCount",
"thinkingLevelSelection",
] as const satisfies ReadonlyArray<keyof SessionEntry>;
const MODEL_ROUTE_OVERRIDE_FIELDS = [
@@ -36,11 +37,13 @@ const MODEL_OVERRIDE_DEPENDENT_FIELDS = new Set<keyof SessionEntry>([
...MODEL_OVERRIDE_RUNTIME_FIELDS,
"liveModelSwitchPending",
"thinkingLevel",
"thinkingLevelSelection",
]);
const MODEL_OVERRIDE_CONFLICT_DEPENDENT_FIELDS = ["thinkingLevel"] as const satisfies ReadonlyArray<
keyof SessionEntry
>;
const MODEL_OVERRIDE_CONFLICT_DEPENDENT_FIELDS = [
"thinkingLevel",
"thinkingLevelSelection",
] as const satisfies ReadonlyArray<keyof SessionEntry>;
const MAIN_SESSION_RECOVERY_TRANSACTION_FIELDS = [
"abortedLastRun",
@@ -0,0 +1,6 @@
export type SessionThinkingLevelSelection = {
provider: string;
model: string;
agentRuntime: string;
level: string;
};
+9 -2
View File
@@ -33,10 +33,14 @@ import type {
SessionOwnerAssignment,
SessionParticipant,
} from "./session-entry-provenance.js";
import type { AgentPatchedSessionModelFallback } from "./session-model-fallback.js";
import type {
AgentPatchedSessionModelFallback,
InternalAgentPatchedSessionModelFallback,
} from "./session-model-fallback.js";
import type { SessionSkillSnapshot } from "./session-prompt-types.js";
import type { SessionSystemPromptReport } from "./session-system-prompt-report.js";
import type { SessionToolOverrides } from "./session-tool-overrides.js";
import type { SessionThinkingLevelSelection } from "./thinking-level-selection.js";
export type { SessionToolOverrides } from "./session-tool-overrides.js";
export type { SessionSystemPromptReport } from "./session-system-prompt-report.js";
@@ -621,7 +625,10 @@ type SessionEntryCore = SessionRestartRecoveryState &
export interface SessionEntry extends SessionEntryCore {}
/** Internal durable fields excluded from public/plugin session projections. */
export type InternalSessionEntryCore = SessionEntryCore & {
export type InternalSessionEntryCore = Omit<SessionEntryCore, "modelFallback"> & {
modelFallback?: InternalAgentPatchedSessionModelFallback;
/** Exact model/runtime fact that validated the persisted thinking override. */
thinkingLevelSelection?: SessionThinkingLevelSelection;
/** Run that owns the current non-terminal Gateway lifecycle projection. */
lifecycleRunId?: string;
/** Run admitted by the session lane; overwritten at admission and checked by transcript writes. */
+9 -1
View File
@@ -1,6 +1,6 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { resolveSessionModelRef } from "../agents/session-model-ref.js";
import type { SessionEntry } from "../config/sessions.js";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js";
import { createAgentPatchedSessionModelFallback } from "../config/sessions/session-model-fallback.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -14,6 +14,14 @@ export function isAgentSessionModelPatchOrigin(): boolean {
return agentSessionModelPatch.getStore() === true;
}
export function updateAgentModelFallbackThinking(
fallback: NonNullable<SessionEntry["modelFallback"]>,
entry: Pick<SessionEntry, "thinkingLevel" | "thinkingLevelSelection">,
): void {
fallback.prevThinkingLevel = entry.thinkingLevel;
fallback.prevThinkingLevelSelection = entry.thinkingLevelSelection;
}
export function snapshotAgentModelFallback(
cfg: OpenClawConfig,
entry: SessionEntry,
+1
View File
@@ -1512,6 +1512,7 @@ export async function performGatewaySessionReset(params: {
systemSent: false,
abortedLastRun: false,
thinkingLevel: currentEntry?.thinkingLevel,
thinkingLevelSelection: currentEntry?.thinkingLevelSelection,
fastMode: currentEntry?.fastMode,
toolOverrides: currentEntry?.toolOverrides,
verboseLevel: currentEntry?.verboseLevel,
+43 -13
View File
@@ -28,6 +28,7 @@ import {
import { resolveThinkingDefaultCore } from "../agents/model-thinking-default-core.js";
import { publishedModelCatalogOwnerMatchesAgent } from "../agents/prepared-model-catalog-owner.js";
import { resolveSessionModelRef } from "../agents/session-model-ref.js";
import { sessionThinkingLevelSelectionMatches } from "../agents/session-thinking-level-selection.js";
import {
concretizeAgentRuntime,
resolveEffectiveAgentRuntime,
@@ -36,9 +37,12 @@ import {
normalizeThinkLevel,
resolveSupportedThinkingLevel,
resolveThinkingProfile,
type ThinkLevel,
} from "../auto-reply/thinking.js";
import { THINKING_LEVEL_RANKS } from "../auto-reply/thinking.shared.js";
import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js";
import { resolveAgentMainSessionKey, type SessionEntry } from "../config/sessions.js";
import { projectPublicSessionEntry } from "../config/sessions/session-entry-projection.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { LEGACY_IMPLICIT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
@@ -71,6 +75,17 @@ function listGatewayThinkingLevelOptions(params: {
}).levels.map(({ id, label }) => ({ id, label }));
}
function includeRecordedThinkingLevel(
levels: ReturnType<typeof listGatewayThinkingLevelOptions>,
level: ThinkLevel,
) {
return levels.some((entry) => entry.id === level)
? levels
: [...levels, { id: level, label: level }].toSorted(
(left, right) => THINKING_LEVEL_RANKS[left.id] - THINKING_LEVEL_RANKS[right.id],
);
}
function resolveGatewaySessionThinkingLevel(params: {
provider: string;
model: string;
@@ -275,23 +290,38 @@ export function resolveGatewaySessionThinkingProjectionInternal(
providerPolicySource: params.providerPolicySource,
});
const storedThinkingLevel = normalizeThinkLevel(params.entry?.thinkingLevel);
const thinkingLevel = storedThinkingLevel
? resolveGatewaySessionThinkingLevel({
provider: params.provider,
model: params.model,
level: storedThinkingLevel,
modelCatalog: params.modelCatalog,
agentRuntime: thinkingRuntime,
providerPolicySource: params.providerPolicySource,
})
: undefined;
const recordedSelectionMatches =
storedThinkingLevel !== undefined &&
sessionThinkingLevelSelectionMatches({
entry: params.entry,
provider: params.provider,
model: params.model,
agentRuntime: thinkingRuntime,
level: storedThinkingLevel,
});
const thinkingLevel = recordedSelectionMatches
? storedThinkingLevel
: storedThinkingLevel
? resolveGatewaySessionThinkingLevel({
provider: params.provider,
model: params.model,
level: storedThinkingLevel,
modelCatalog: params.modelCatalog,
agentRuntime: thinkingRuntime,
providerPolicySource: params.providerPolicySource,
})
: undefined;
const thinkingLevels =
recordedSelectionMatches && storedThinkingLevel
? includeRecordedThinkingLevel(metadata.thinkingLevels, storedThinkingLevel)
: metadata.thinkingLevels;
return {
agentRuntime,
thinkingLevel,
effectiveThinkingLevel: thinkingLevel ?? metadata.thinkingDefault,
// Preserve the established serialized projection order for byte-stable responses.
thinkingLevels: metadata.thinkingLevels,
thinkingOptions: metadata.thinkingLevels.map((level) => level.label),
thinkingLevels,
thinkingOptions: thinkingLevels.map((level) => level.label),
thinkingDefault: metadata.thinkingDefault,
};
}
@@ -670,7 +700,7 @@ export async function projectSessionPatchResult(params: {
agentId: params.targetAgentId,
}).path,
key: params.canonicalKey,
entry: params.entry,
entry: projectPublicSessionEntry(params.entry),
resolved: {
modelProvider: displayModel.provider,
model: displayModel.model,
+91 -40
View File
@@ -9,7 +9,7 @@ import { resolveLegacyInheritedAuthAgentId } from "../agents/legacy-inherited-au
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { retainLegacyDefaultAgentId } from "../config/legacy.default-agent-owner.js";
import type { SessionEntry } from "../config/sessions.js";
import type { InternalSessionEntry, SessionEntry } from "../config/sessions.js";
import {
appendTranscriptMessageSync,
listSessionChildEntriesReadOnly,
@@ -31,7 +31,11 @@ import { buildGatewaySessionEventFields } from "./session-event-payload.js";
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
import { deriveSessionTitle } from "./session-utils-core.js";
import { listSessionsFromStore, listSessionsFromStoreAsync } from "./session-utils-list.js";
import { getSessionDefaults, resolveGatewayModelSupportsImages } from "./session-utils-model.js";
import {
getSessionDefaults,
projectSessionPatchResult,
resolveGatewayModelSupportsImages,
} from "./session-utils-model.js";
import {
buildSessionListRowContext,
buildSingleRowStoreChildSessionsByKey,
@@ -1128,16 +1132,15 @@ describe("gateway session utils", () => {
);
});
test("preserves persisted Ultra while projecting picker levels without a catalog", () => {
test("preserves recorded harness thinking selections and clamps unproven state", () => {
providerArtifactMocks.resolveBundledProviderPolicySurface.mockReturnValue({
resolveThinkingProfile: ({ modelId, agentRuntime }) => ({
resolveThinkingProfile: ({ compat }) => ({
levels: [
{ id: "off" },
{ id: "high" },
{ id: "xhigh" },
{ id: "max" },
...(modelId.startsWith("gpt-5.6") &&
(agentRuntime === "openclaw" || !modelId.startsWith("gpt-5.6-luna"))
...(compat?.supportedReasoningEfforts?.includes("ultra")
? [{ id: "ultra" as const }]
: []),
],
@@ -1146,14 +1149,21 @@ describe("gateway session utils", () => {
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.6-luna" },
model: { primary: "openai/gpt-5.6-sol" },
models: {
"openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
"openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
},
},
},
} as OpenClawConfig;
const defaults = getSessionDefaults(cfg);
const modelCatalog = [
{
provider: "openai",
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol (API route)",
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] },
},
];
const row = (entry: SessionEntry) =>
buildGatewaySessionRow({
cfg,
@@ -1161,43 +1171,84 @@ describe("gateway session utils", () => {
store: {},
key: "agent:main:main",
entry,
modelCatalog,
});
const codex = row({ sessionId: "codex", thinkingLevel: "ultra" } as SessionEntry);
const openClawOverride = row({
sessionId: "openclaw",
const recorded = row({
sessionId: "recorded",
thinkingLevel: "ultra",
agentRuntimeOverride: "openclaw",
} as SessionEntry);
const legacyObservedOpenClaw = row({
sessionId: "legacy-observed-openclaw",
thinkingLevelSelection: {
provider: "openai",
model: "gpt-5.6-sol",
agentRuntime: "codex",
level: "ultra",
},
} as InternalSessionEntry);
const unproven = row({ sessionId: "unproven", thinkingLevel: "ultra" } as SessionEntry);
const staleRuntime = row({
sessionId: "stale-runtime",
thinkingLevel: "ultra",
agentHarnessId: "openclaw",
} as SessionEntry);
const lockedCodex = row({
sessionId: "locked-codex",
thinkingLevel: "ultra",
agentHarnessId: "codex",
agentRuntimeOverride: "openclaw",
modelSelectionLocked: true,
} as SessionEntry);
thinkingLevelSelection: {
provider: "openai",
model: "gpt-5.6-sol",
agentRuntime: "openclaw",
level: "ultra",
},
} as InternalSessionEntry);
expect(defaults.agentRuntime?.id).toBe("codex");
expect(codex.thinkingLevel).toBe("ultra");
expect(codex.thinkingLevels?.map((level) => level.id)).not.toContain("ultra");
expect(openClawOverride.thinkingLevel).toBe("ultra");
expect(openClawOverride.agentRuntime?.id).toBe("openclaw");
expect(legacyObservedOpenClaw.thinkingLevel).toBe("ultra");
expect(legacyObservedOpenClaw.agentRuntime?.id).toBe("codex");
expect(legacyObservedOpenClaw.thinkingLevels?.map((level) => level.id)).not.toContain("ultra");
expect(lockedCodex.thinkingLevel).toBe("ultra");
expect(lockedCodex.agentRuntime).toEqual({
id: "codex",
cloudPlacementSupported: false,
devicePlacementSupported: false,
source: "session",
expect(recorded.thinkingLevel).toBe("ultra");
expect(recorded.thinkingLevels?.map((level) => level.id)).toContain("ultra");
expect(unproven.thinkingLevel).toBe("max");
expect(staleRuntime.thinkingLevel).toBe("max");
});
test("strips nested thinking provenance from Gateway patch results", async () => {
const entry: InternalSessionEntry = {
sessionId: "private-fallback",
updatedAt: 1,
modelFallback: {
prevModel: "gpt-5.6-sol",
prevProvider: "openai",
prevThinkingLevelSelection: {
provider: "openai",
model: "gpt-5.6-sol",
agentRuntime: "codex",
level: "ultra",
},
source: "agent-patch",
ts: 1,
},
};
const result = await projectSessionPatchResult({
canonicalKey: "agent:main:main",
cfg: {
agents: { defaults: { model: { primary: "openai/gpt-5.6-sol" } } },
} as OpenClawConfig,
entry,
modelCatalogByAgent: new Map([
[
"main",
Promise.resolve([
{
provider: "openai",
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
reasoning: true,
},
]),
],
]),
storePath: "/tmp/openclaw-sessions.json",
targetAgentId: "main",
});
expect(lockedCodex.thinkingLevels?.map((level) => level.id)).not.toContain("ultra");
expect(result.entry.modelFallback).toEqual({
prevModel: "gpt-5.6-sol",
prevProvider: "openai",
source: "agent-patch",
ts: 1,
});
expect(JSON.stringify(result.entry)).not.toContain("prevThinkingLevelSelection");
});
test("reports observed locked runtime from agentHarnessId instead of configured intent", () => {
+13 -1
View File
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { SessionCreatedActor } from "../../packages/gateway-protocol/src/index.js";
import { resetProviderAuthAliasMapCacheForTest } from "../agents/provider-auth-aliases.test-support.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import type { InternalSessionEntry, SessionEntry } from "../config/sessions.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
@@ -1330,6 +1330,12 @@ describe("gateway sessions patch", () => {
);
expect(entry.thinkingLevel).toBe("ultra");
expect((entry as InternalSessionEntry).thinkingLevelSelection).toEqual({
provider: "openai",
model: "gpt-5.6-luna",
agentRuntime: "openclaw",
level: "ultra",
});
});
test("remaps stored Ultra to Max when a model patch selects Codex Luna", async () => {
@@ -1352,6 +1358,12 @@ describe("gateway sessions patch", () => {
);
expect(entry.thinkingLevel).toBe("max");
expect((entry as InternalSessionEntry).thinkingLevelSelection).toEqual({
provider: "openai",
model: "gpt-5.6-luna",
agentRuntime: "codex",
level: "max",
});
});
test("honors an explicit OpenClaw session runtime override for Luna Ultra", async () => {
+23 -7
View File
@@ -24,6 +24,10 @@ import {
resolveDefaultModelForAgent,
resolveSubagentConfiguredModelSelection,
} from "../agents/model-selection.js";
import {
readSessionThinkingLevelSelection,
updateSessionThinkingLevelSelection,
} from "../agents/session-thinking-level-selection.js";
import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
@@ -36,7 +40,10 @@ import {
normalizeUsageDisplay,
resolveSupportedThinkingLevel,
} from "../auto-reply/thinking.js";
import type { SessionEntry, SessionToolOverrides } from "../config/sessions.js";
import type {
InternalSessionEntry as SessionEntry,
SessionToolOverrides,
} from "../config/sessions.js";
import { projectCanonicalSessionEntryShape } from "../config/sessions/store-entry-shape.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeExecTarget } from "../infra/exec-approvals.js";
@@ -73,6 +80,7 @@ import { parseSessionLabel, SESSION_LABEL_MAX_LENGTH } from "../sessions/session
import {
isAgentSessionModelPatchOrigin,
snapshotAgentModelFallback,
updateAgentModelFallbackThinking,
} from "./session-model-patch-origin.js";
import { applySessionsPatchSubagentPolicy } from "./sessions-patch-subagent-policy.js";
@@ -653,15 +661,16 @@ export async function projectSessionsPatchEntry(params: {
}
}
if (next.thinkingLevel && ("thinkingLevel" in patch || "model" in patch)) {
if ("thinkingLevel" in patch || "model" in patch) {
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
const thinkingLevel = normalizeThinkLevel(next.thinkingLevel);
const thinkingCatalog = await loadPreparedModelCatalogForPatch();
let thinkingRuntime: string | undefined;
if (!thinkingLevel) {
delete next.thinkingLevel;
} else {
const thinkingRuntime = resolveThinkingRuntime(effectiveProvider, effectiveModel, next);
const thinkingCatalog = await loadPreparedModelCatalogForPatch();
thinkingRuntime = resolveThinkingRuntime(effectiveProvider, effectiveModel, next);
if (
!isThinkingLevelSupported({
provider: effectiveProvider,
@@ -685,6 +694,12 @@ export async function projectSessionsPatchEntry(params: {
});
}
}
updateSessionThinkingLevelSelection(next, {
provider: effectiveProvider,
model: effectiveModel,
agentRuntime: thinkingRuntime,
level: next.thinkingLevel,
});
}
// A thinkingLevel change made on its own (no model switch) never touches the
@@ -695,9 +710,10 @@ export async function projectSessionsPatchEntry(params: {
!("model" in patch) &&
next.modelFallback?.source === "agent-patch"
) {
next.modelFallback = next.thinkingLevel
? { ...next.modelFallback, prevThinkingLevel: next.thinkingLevel }
: { ...next.modelFallback, prevThinkingLevel: undefined };
updateAgentModelFallbackThinking(next.modelFallback, {
thinkingLevel: next.thinkingLevel,
thinkingLevelSelection: readSessionThinkingLevelSelection(next),
});
}
if ("sendPolicy" in patch) {
@@ -10,6 +10,7 @@ import {
resolveCompatibleAgentRuntimeForProvider,
resolveSessionRuntimeOverrideForProvider,
} from "../agents/session-runtime-compat.js";
import { updateSessionThinkingLevelSelection } from "../agents/session-thinking-level-selection.js";
import {
persistStickyModelSelectionBestEffort,
type StickyModelSelectionDispatchOutcome,
@@ -27,7 +28,7 @@ import {
SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS,
sessionModelOverrideChangesApplied,
} from "../config/sessions/session-snapshot-merge.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { triggerSessionPatchHook } from "../gateway/session-patch-hooks.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
@@ -269,6 +270,12 @@ export async function applySessionModelSelection(
};
}
}
updateSessionThinkingLevelSelection(nextEntry, {
provider: request.provider,
model: request.model,
agentRuntime: thinkingRuntime,
level: nextEntry.thinkingLevel,
});
// An explicit selection retains the existing persistence and conflict semantics even when idempotent.
nextEntry.updatedAt = Date.now();
@@ -86,6 +86,9 @@ export function generationValidPrivateFieldsForSameSession(
mainRestartRecovery: existingEntry.mainRestartRecovery,
}
: {}),
...(existingEntry.thinkingLevelSelection
? { thinkingLevelSelection: { ...existingEntry.thinkingLevelSelection } }
: {}),
};
return Object.keys(state).length > 0 ? state : undefined;
}
@@ -28,6 +28,12 @@ function privateGenerationEntry(): InternalSessionEntry {
status: "pending",
},
sessionId: "session-1",
thinkingLevelSelection: {
provider: "openai",
model: "gpt-5.6-sol",
agentRuntime: "codex",
level: "ultra",
},
updatedAt: 10,
};
}
@@ -36,6 +42,7 @@ function expectGenerationPrivateFieldsCleared(entry: InternalSessionEntry | unde
expect(entry?.activeWriterRunId).toBeUndefined();
expect(entry?.lifecycleRunId).toBeUndefined();
expect(entry?.sessionDiffBaselineCapture).toBeUndefined();
expect(entry?.thinkingLevelSelection).toBeUndefined();
}
const sessionEntryKeepsWriterClaimPrivate: "activeWriterRunId" extends keyof SessionEntry
@@ -46,6 +53,16 @@ const sessionEntryKeepsBaselineClaimPrivate: "sessionDiffBaselineCapture" extend
? false
: true = true;
void sessionEntryKeepsBaselineClaimPrivate;
const sessionEntryKeepsThinkingSelectionPrivate: "thinkingLevelSelection" extends keyof SessionEntry
? false
: true = true;
void sessionEntryKeepsThinkingSelectionPrivate;
const sessionFallbackKeepsThinkingSelectionPrivate: "prevThinkingLevelSelection" extends keyof NonNullable<
SessionEntry["modelFallback"]
>
? false
: true = true;
void sessionFallbackKeepsThinkingSelectionPrivate;
describe("plugin session writer claim projection", () => {
it("excludes the durable writer claim from entries and patches", () => {
@@ -58,12 +75,36 @@ describe("plugin session writer claim projection", () => {
status: "pending",
},
model: "gpt-5.6",
modelFallback: {
prevModel: "gpt-5.5",
prevProvider: "openai",
prevThinkingLevelSelection: {
provider: "openai",
model: "gpt-5.5",
agentRuntime: "codex",
level: "max",
},
source: "agent-patch",
ts: 1,
},
sessionId: "session-writer",
thinkingLevelSelection: {
provider: "openai",
model: "gpt-5.6-sol",
agentRuntime: "codex",
level: "ultra",
},
updatedAt: 10,
};
expect(projectPluginSessionEntry(entry)).toEqual({
model: "gpt-5.6",
modelFallback: {
prevModel: "gpt-5.5",
prevProvider: "openai",
source: "agent-patch",
ts: 1,
},
sessionId: "session-writer",
updatedAt: 10,
});
@@ -77,8 +118,34 @@ describe("plugin session writer claim projection", () => {
status: "pending",
},
model: "gpt-5.5",
modelFallback: {
prevModel: "gpt-5.4",
prevProvider: "openai",
prevThinkingLevelSelection: {
provider: "openai",
model: "gpt-5.4",
agentRuntime: "codex",
level: "max",
},
source: "agent-patch",
ts: 2,
},
thinkingLevelSelection: {
provider: "openai",
model: "gpt-5.5",
agentRuntime: "openclaw",
level: "max",
},
}),
).toEqual({ model: "gpt-5.5" });
).toEqual({
model: "gpt-5.5",
modelFallback: {
prevModel: "gpt-5.4",
prevProvider: "openai",
source: "agent-patch",
ts: 2,
},
});
});
it("preserves private generation fields when patches and upserts omit lifecycle revision", async () => {
@@ -98,6 +165,7 @@ describe("plugin session writer claim projection", () => {
lifecycleRunId: "lifecycle-run",
model: "gpt-5.6",
sessionDiffBaselineCapture: { captureId: "capture-1", status: "pending" },
thinkingLevelSelection: { model: "gpt-5.6-sol", level: "ultra" },
});
await upsertSessionEntry({
@@ -110,6 +178,7 @@ describe("plugin session writer claim projection", () => {
lifecycleRevision: "generation-1",
lifecycleRunId: "lifecycle-run",
sessionDiffBaselineCapture: { captureId: "capture-1", status: "pending" },
thinkingLevelSelection: { model: "gpt-5.6-sol", level: "ultra" },
});
});
+1
View File
@@ -79,6 +79,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"abortCutoffTimestamp",
"chatType",
"thinkingLevel",
"thinkingLevelSelection",
"cronRunContinuation",
"fastMode",
"toolOverrides",