diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 9dce2cd30198..b2f610a75c60 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -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, }); }); diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 288df555d10c..85be4523eb47 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -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). diff --git a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts index 442d2c472ee3..44aa89ff9bdf 100644 --- a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts +++ b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts @@ -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. diff --git a/src/agents/embedded-agent-runner/model-resolution-consistency.test.ts b/src/agents/embedded-agent-runner/model-resolution-consistency.test.ts new file mode 100644 index 000000000000..43450f70e011 --- /dev/null +++ b/src/agents/embedded-agent-runner/model-resolution-consistency.test.ts @@ -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, + }); + }); +}); diff --git a/src/agents/embedded-agent-runner/model-resolution.ts b/src/agents/embedded-agent-runner/model-resolution.ts new file mode 100644 index 000000000000..9a2f38d69eaf --- /dev/null +++ b/src/agents/embedded-agent-runner/model-resolution.ts @@ -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>; + +/** 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[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!, + } + ); +} diff --git a/src/agents/embedded-agent-runner/run/model-setup.ts b/src/agents/embedded-agent-runner/run/model-setup.ts index a3c1330b3c3b..4a484f350a21 100644 --- a/src/agents/embedded-agent-runner/run/model-setup.ts +++ b/src/agents/embedded-agent-runner/run/model-setup.ts @@ -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> | undefined; - let modelResolution: Awaited> | 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}`, {