fix: compaction resolves undated model refs (#122422)

* fix(agents): unify tiered model resolution for chat and compaction

Manual /compact failed with Unknown_model when the agent model was
configured as an undated ref (e.g. anthropic/claude-haiku-4-5): the chat
run resolved models through a two-tier path (discovery-free lookup, then
prepared stores with bundled static-catalog fallback) while both
compaction entry points made a single bare resolveModelAsync call and
dead-ended before the static catalog could resolve the undated id.

resolveTieredModel is now the canonical resolution owner used by the
chat run, direct compaction, and queued compaction; the duplicated
chat-only tier logic and both bare compaction lookups are removed.
Regression test proves chat and manual compaction resolve the same
undated configured model through the shared owner.

* fix(agents): satisfy lint and test-types on tiered resolution call sites

Drop the redundant ?? {} spread fallbacks (spreading undefined is a
no-op) and give the test registry mock its real (provider, modelId)
signature for check:test-types.
This commit is contained in:
Peter Steinberger
2026-08-11 22:08:54 -07:00
committed by GitHub
parent bad4d34982
commit a7e4065dd7
6 changed files with 342 additions and 94 deletions
@@ -2420,7 +2420,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
}
});
it("uses the acquired gateway runtime generation for queued model resolution", async () => {
it("uses the acquired gateway runtime generation for queued tiered model resolution", async () => {
await compactEmbeddedAgentSession(
wrappedCompactionArgs({
allowGatewaySubagentBinding: true,
@@ -2434,9 +2434,8 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
: undefined;
expect(snapshot).toBeDefined();
expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toMatchObject({
authStorage: {},
modelRegistry: {},
preparedModelRuntime: snapshot,
skipAgentDiscovery: true,
});
});
@@ -56,6 +56,7 @@ import { resolveContextEngineCapabilities } from "./context-engine-capabilities.
import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { resolveTieredModel } from "./model-resolution.js";
import { resolveModelAsync } from "./model.js";
import type { EmbeddedAgentQueueHandle } from "./run-state.js";
import {
@@ -437,7 +438,6 @@ async function compactResolvedContextEngine(
let preparedHarnessRuntime = selectedHarnessRuntime;
let preparedParams = params;
try {
const preparedStores = preparedModelRuntime.createStores();
// Ensure the policy-selected harness plugin so selection can pick implicit codex.
await ensureSelectedAgentHarnessPlugin({
config: params.config,
@@ -450,15 +450,16 @@ async function compactResolvedContextEngine(
workspaceDir: resolvedWorkspaceDir,
pluginRegistry: requireActivePluginRegistry(),
});
const {
model: ceModel,
authStorage,
modelRegistry,
} = await resolveModelAsync(ceRuntimeProvider, ceModelId, agentDir, params.config, {
const { resolution: modelResolution } = await resolveTieredModel({
provider: ceRuntimeProvider,
modelId: ceModelId,
agentDir,
config: params.config,
workspaceDir: resolvedWorkspaceDir,
...initialModelAuth,
...preparedStores,
preparedModelRuntime,
});
const { model: ceModel, authStorage, modelRegistry } = modelResolution;
const ceRuntimeModel = ceModel as ProviderRuntimeModel | undefined;
// Overrides stay unset when no bound/planned/explicit harness resolved so auth-aware
// selection can pick the credential-owning harness (codex for ChatGPT OAuth).
@@ -42,6 +42,7 @@ import {
resolveCompactionRuntimeSelection,
} from "./compaction-runtime-preparation.js";
import { log } from "./logger.js";
import { resolveTieredModel } from "./model-resolution.js";
import { resolveModelAsync } from "./model.js";
import type { EmbeddedAgentCompactResult } from "./types.js";
@@ -135,25 +136,26 @@ export async function prepareDirectCompactionAttempt(
};
};
const preparedModelRuntime = params.preparedModelRuntime;
const modelResolutionOptions = {
...preparedModelRuntime.createStores(),
preparedModelRuntime,
workspaceDir: resolvedWorkspace,
};
const { model, error, authStorage, modelRegistry } = await resolveModelAsync(
runtimeProvider,
const { resolution: modelResolution } = await resolveTieredModel({
provider: runtimeProvider,
modelId,
agentDir,
params.config,
{
...initialModelAuth,
...modelResolutionOptions,
},
);
config: params.config,
workspaceDir: resolvedWorkspace,
...initialModelAuth,
preparedModelRuntime,
});
const { model, error, authStorage, modelRegistry } = modelResolution;
if (!model) {
const reason = error ?? `Unknown model: ${runtimeProvider}/${modelId}`;
return { ok: false as const, result: fail(reason) };
}
const modelResolutionOptions = {
authStorage,
modelRegistry,
preparedModelRuntime,
workspaceDir: resolvedWorkspace,
};
// Overrides stay unset when no bound/planned/explicit harness resolved so auth-aware
// selection can pick the credential-owning harness (codex for ChatGPT OAuth); native
// transcript compaction stays gated on the selected prepared harness.
@@ -0,0 +1,214 @@
import { describe, expect, it, vi } from "vitest";
import { resolveInitialEmbeddedRunModel } from "./run/runtime-resolution.js";
const STATIC_MODEL_ID = "claude-haiku-4-5";
const PROVIDER = "anthropic";
const emptyModelRegistry = {
find: vi.fn((_provider: string, _modelId: string) => null),
};
const authStorage = {
setRuntimeApiKey: vi.fn(),
};
const staticCatalogModel = {
provider: PROVIDER,
id: STATIC_MODEL_ID,
name: "Claude Haiku 4.5",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"],
contextWindow: 200_000,
maxTokens: 64_000,
};
const resolveModelAsyncMock = vi.fn(
async (
provider: string,
modelId: string,
_agentDir?: string,
_config?: unknown,
options?: {
allowBundledStaticCatalogFallback?: boolean;
authStorage?: unknown;
modelRegistry?: unknown;
},
) => {
const stores = {
authStorage: options?.authStorage ?? authStorage,
modelRegistry: options?.modelRegistry ?? emptyModelRegistry,
};
if (options?.allowBundledStaticCatalogFallback) {
return { ...stores, model: staticCatalogModel };
}
return {
...stores,
error: `Unknown model: ${provider}/${modelId}`,
};
},
);
vi.mock("./model.js", () => ({
createEmptyAgentDiscoveryStores: () => ({ authStorage, modelRegistry: emptyModelRegistry }),
resolveModelAsync: resolveModelAsyncMock,
}));
vi.mock("../harness/runtime-plugin.js", () => ({
ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
}));
vi.mock("../harness/selection.js", () => ({
selectAgentHarness: vi.fn(() => ({
id: "openclaw",
label: "OpenClaw",
supports: () => ({ supported: true }),
runAttempt: vi.fn(),
})),
}));
vi.mock("../openai-routing.js", () => ({
resolveSelectedOpenAIRuntimeProvider: ({ provider }: { provider: string }) => provider,
}));
vi.mock("../prepared-model-runtime.js", () => ({
prepareModelRuntimeSnapshot: vi.fn(),
}));
vi.mock("./run/setup.js", () => ({
buildBeforeModelResolveAttachments: vi.fn(() => []),
createNativeModelOwnedRuntimeModel: vi.fn(),
resolveHookModelSelection: vi.fn(
async ({ provider, modelId }: { provider: string; modelId: string }) => ({
provider,
modelId,
}),
),
resolveNativeModelOwnedHarnessId: vi.fn(() => undefined),
}));
vi.mock("./compaction-runtime-preparation.js", () => ({
resolveCompactionRuntimeSelection: ({
provider,
modelId,
}: {
provider: string;
modelId: string;
}) => ({
runtimePolicySessionKey: "agent:main:test",
runtimePolicyAgentId: "main",
boundHarnessRuntime: undefined,
selectedHarnessRuntimeOverride: undefined,
runtimeModelAuth: { plan: undefined, authProfileId: undefined, modelAuth: undefined },
provider,
runtimeProvider: provider,
contextConfigProvider: provider,
modelId,
}),
prepareCompactionHarnessAuth: vi.fn(async () => ({
runtimeAuthProfileStore: {},
runtimeAuthPreparation: {
plan: { selectedAuthMode: "api-key" },
attempts: [{ kind: "direct", plan: { selectedAuthMode: "api-key" } }],
},
selectedPreparedHarness: { id: "openclaw" },
providerUsesProfileScopedModelMetadata: false,
})),
}));
vi.mock("../runtime-plan/resolve-auth.js", () => ({
resolvePreparedRuntimeAuthAttempts: vi.fn(async ({ model, attempts }) => ({
model,
auth: { apiKey: "test-api-key", mode: "api_key", source: "test" },
plan: attempts[0].plan,
})),
resolvePreparedRuntimeModelAuth: vi.fn(),
}));
vi.mock("../../plugins/provider-runtime.js", () => ({
prepareProviderRuntimeAuth: vi.fn(async () => undefined),
}));
vi.mock("../provider-secret-egress.js", () => ({
protectPreparedProviderRuntimeAuth: (value: unknown) => value,
unwrapSecretSentinelsForProviderEgress: (value: unknown) => value,
}));
vi.mock("../provider-request-config.js", () => ({
applyPreparedRuntimeAuthToModel: (model: unknown) => model,
}));
vi.mock("../sandbox.js", () => ({
resolveSandboxContext: vi.fn(async () => undefined),
}));
vi.mock("./compaction-runtime-context.js", () => ({
resolveEmbeddedCompactionThinkingLevel: vi.fn(() => "off"),
}));
vi.mock("./logger.js", () => ({
log: { warn: vi.fn() },
}));
const { resolveEmbeddedRunModelSetup } = await import("./run/model-setup.js");
const { prepareDirectCompactionAttempt } = await import("./direct-compaction-preparation.js");
describe("embedded model resolution consistency", () => {
it("resolves the same undated configured model for chat and manual compaction", async () => {
const config = {
agents: {
defaults: {
model: { primary: `${PROVIDER}/${STATIC_MODEL_ID}` },
},
},
};
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 chat = await resolveEmbeddedRunModelSetup({
runParams: {
config,
prompt: "hello",
sessionId: "chat-session",
agentId: "main",
} as never,
...target,
agentDir: preparedModelRuntime.agentDir,
workspaceDir: preparedModelRuntime.workspaceDir,
globalLane: "test",
hookRunner: undefined,
hookContext: {} as never,
onHooksResolved: vi.fn(),
preparedModelRuntime: preparedModelRuntime as never,
});
expect(chat.model).toMatchObject({ provider: PROVIDER, id: STATIC_MODEL_ID });
const compaction = await prepareDirectCompactionAttempt({
config,
provider: target.provider,
model: target.modelId,
agentId: "main",
sessionId: "compact-session",
sessionKey: "agent:main:compact-session",
sessionFile: "agent:main:compact-session",
workspaceDir: preparedModelRuntime.workspaceDir,
preparedModelRuntime: preparedModelRuntime as never,
});
expect(emptyModelRegistry.find(PROVIDER, STATIC_MODEL_ID)).toBeNull();
if (!compaction.ok) {
throw new Error(`manual compaction failed: ${compaction.result.reason}`);
}
expect(compaction.value.runtimeModel).toMatchObject({
provider: PROVIDER,
id: STATIC_MODEL_ID,
});
});
});
@@ -0,0 +1,86 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveDefaultAgentDir } from "../agent-scope.js";
import type { AuthProfileCredential } from "../auth-profiles/types.js";
import {
prepareModelRuntimeSnapshot,
type PreparedModelRuntimeSnapshot,
} from "../prepared-model-runtime.js";
import { resolveModelAsync } from "./model.js";
type ModelResolution = Awaited<ReturnType<typeof resolveModelAsync>>;
/** Resolves embedded-run models through discovery first, then the prepared static catalog. */
export async function resolveTieredModel(params: {
provider: string;
fallbackProvider?: string;
modelId: string;
agentDir: string;
config?: OpenClawConfig;
workspaceDir: string;
authProfileId?: string;
authProfileMode?: AuthProfileCredential["type"] | "aws-sdk";
preparedModelRuntime?: PreparedModelRuntimeSnapshot;
staticCatalogOwnsTransport?: boolean;
}): Promise<{ provider: string; resolution: ModelResolution }> {
const providers =
params.fallbackProvider && params.fallbackProvider !== params.provider
? [params.provider, params.fallbackProvider]
: [params.provider];
let firstResolution: ModelResolution | undefined;
const resolveCandidates = async (options: Parameters<typeof resolveModelAsync>[4]) => {
for (const provider of providers) {
const resolution = await resolveModelAsync(
provider,
params.modelId,
params.agentDir,
params.config,
options,
);
firstResolution ??= resolution;
if (resolution.model) {
return { provider, resolution };
}
}
return undefined;
};
const firstTier = await resolveCandidates({
skipAgentDiscovery: true,
allowBundledStaticCatalogFallback: params.staticCatalogOwnsTransport,
preferBundledStaticCatalogTransport: params.staticCatalogOwnsTransport,
preparedModelRuntime: params.preparedModelRuntime,
workspaceDir: params.workspaceDir,
authProfileId: params.authProfileId,
authProfileMode: params.authProfileMode,
});
if (firstTier) {
return firstTier;
}
if (params.staticCatalogOwnsTransport) {
return {
provider: params.fallbackProvider ?? params.provider,
resolution: firstResolution!,
};
}
const config = params.config ?? {};
const preparedModelRuntime =
params.preparedModelRuntime ??
(await prepareModelRuntimeSnapshot({
config,
agentDir: params.agentDir,
inheritedAuthDir: resolveDefaultAgentDir(config),
workspaceDir: params.workspaceDir,
}));
return (
(await resolveCandidates({
...preparedModelRuntime.createStores(),
workspaceDir: params.workspaceDir,
authProfileId: params.authProfileId,
authProfileMode: params.authProfileMode,
allowBundledStaticCatalogFallback: true,
preparedModelRuntime,
})) ?? {
provider: params.fallbackProvider ?? params.provider,
resolution: firstResolution!,
}
);
}
@@ -1,14 +1,11 @@
import { requireActivePluginRegistry } from "../../../plugins/runtime.js";
import { resolveDefaultAgentDir } from "../../agent-scope.js";
import { FailoverError } from "../../failover-error.js";
import { ensureSelectedAgentHarnessPlugin } from "../../harness/runtime-plugin.js";
import { selectAgentHarness } from "../../harness/selection.js";
import { resolveSelectedOpenAIRuntimeProvider } from "../../openai-routing.js";
import {
prepareModelRuntimeSnapshot,
type PreparedModelRuntimeSnapshot,
} from "../../prepared-model-runtime.js";
import { createEmptyAgentDiscoveryStores, resolveModelAsync } from "../model.js";
import type { PreparedModelRuntimeSnapshot } from "../../prepared-model-runtime.js";
import { resolveTieredModel } from "../model-resolution.js";
import { createEmptyAgentDiscoveryStores } from "../model.js";
import type { RunEmbeddedAgentParams } from "./params.js";
import { resolveRequestStreamTransportOverrides } from "./runtime-resolution.js";
import {
@@ -99,8 +96,7 @@ export async function resolveEmbeddedRunModelSetup(params: {
const nativeModelOwned = nativeModelOwnedHarnessId !== undefined;
const modelConfigProvider = provider;
let resolvedModelProvider = provider;
let firstModelResolution: Awaited<ReturnType<typeof resolveModelAsync>> | undefined;
let modelResolution: Awaited<ReturnType<typeof resolveModelAsync>> | undefined;
let modelResolution;
if (nativeModelOwned) {
modelResolution = {
model: createNativeModelOwnedRuntimeModel({ provider, modelId }),
@@ -116,69 +112,19 @@ export async function resolveEmbeddedRunModelSetup(params: {
config: runParams.config,
workspaceDir: params.workspaceDir,
});
const modelResolutionProviders =
selectedRuntimeProvider !== provider ? [selectedRuntimeProvider, provider] : [provider];
for (const candidateProvider of modelResolutionProviders) {
const candidateResolution = await resolveModelAsync(
candidateProvider,
modelId,
params.agentDir,
runParams.config,
{
// Dynamic hooks can resolve an explicit model without generating models.json first.
skipAgentDiscovery: true,
allowBundledStaticCatalogFallback: pluginHarnessOwnsTransport,
preferBundledStaticCatalogTransport: pluginHarnessOwnsTransport,
preparedModelRuntime: params.preparedModelRuntime,
workspaceDir: params.workspaceDir,
authProfileId: runParams.authProfileId,
},
);
firstModelResolution ??= candidateResolution;
if (candidateResolution.model) {
resolvedModelProvider = candidateProvider;
modelResolution = candidateResolution;
break;
}
}
if (!modelResolution && pluginHarnessOwnsTransport) {
modelResolution = firstModelResolution;
}
if (!modelResolution) {
const config = runParams.config ?? {};
const preparedModelRuntime =
params.preparedModelRuntime ??
(await prepareModelRuntimeSnapshot({
config,
agentDir: params.agentDir,
inheritedAuthDir: resolveDefaultAgentDir(config),
workspaceDir: params.workspaceDir,
}));
const preparedStores = preparedModelRuntime.createStores();
for (const candidateProvider of modelResolutionProviders) {
const candidateResolution = await resolveModelAsync(
candidateProvider,
modelId,
params.agentDir,
runParams.config,
{
authStorage: preparedStores.authStorage,
modelRegistry: preparedStores.modelRegistry,
workspaceDir: params.workspaceDir,
authProfileId: runParams.authProfileId,
allowBundledStaticCatalogFallback: true,
preparedModelRuntime,
},
);
firstModelResolution ??= candidateResolution;
if (candidateResolution.model) {
resolvedModelProvider = candidateProvider;
modelResolution = candidateResolution;
break;
}
}
}
modelResolution ??= firstModelResolution;
const tieredResolution = await resolveTieredModel({
provider: selectedRuntimeProvider,
...(selectedRuntimeProvider !== provider ? { fallbackProvider: provider } : {}),
modelId,
agentDir: params.agentDir,
config: runParams.config,
workspaceDir: params.workspaceDir,
authProfileId: runParams.authProfileId,
preparedModelRuntime: params.preparedModelRuntime,
staticCatalogOwnsTransport: pluginHarnessOwnsTransport,
});
resolvedModelProvider = tieredResolution.provider;
modelResolution = tieredResolution.resolution;
}
if (!modelResolution) {
throw new FailoverError(`Unknown model: ${provider}/${modelId}`, {