mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
29f86119e0
commit
efc33f882e
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"abortCutoffTimestamp",
|
||||
"chatType",
|
||||
"thinkingLevel",
|
||||
"thinkingLevelSelection",
|
||||
"cronRunContinuation",
|
||||
"fastMode",
|
||||
"toolOverrides",
|
||||
|
||||
Reference in New Issue
Block a user