diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index b377fe0c0bae..b33754753e28 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1827,11 +1827,11 @@ src/agents/embedded-agent-runner/history.ts 3 src/agents/embedded-agent-runner/message-visibility.ts 13 src/agents/embedded-agent-runner/model-context-tokens.ts 1 src/agents/embedded-agent-runner/model.compat.ts 1 -src/agents/embedded-agent-runner/model.configured-fallback.ts 2 -src/agents/embedded-agent-runner/model.configured-overrides.ts 3 +src/agents/embedded-agent-runner/model.configured-fallback.ts 1 +src/agents/embedded-agent-runner/model.configured-overrides.ts 2 src/agents/embedded-agent-runner/model.inline-provider.ts 2 src/agents/embedded-agent-runner/model.provider-hooks.ts 10 -src/agents/embedded-agent-runner/model.registry-resolution.ts 7 +src/agents/embedded-agent-runner/model.registry-resolution.ts 6 src/agents/embedded-agent-runner/model.ts 5 src/agents/embedded-agent-runner/openrouter-model-capabilities.ts 2 src/agents/embedded-agent-runner/prepared-compaction-runtime.ts 1 diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 0c18921203bc..cbcaf7a90d37 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -5,11 +5,21 @@ import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coerci import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; import type { ProviderResolveModelRoutesContext } from "../plugin-sdk/provider-model-types.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { getPluginRuntimeGenerationRegistry } from "../plugins/runtime/generation-scope.js"; import { looksLikeSecretSentinel, mintSecretSentinel, resolveSecretSentinel, } from "../secrets/sentinel.js"; +import { + createModelGenerationFixture, + GENERATION_WORKSPACE_DIR, + publishCurrentModelGeneration, + resetModelGenerationFixtureState, +} from "./embedded-agent-runner/model.generation-scope.test-support.js"; import type { AgentHarnessHostCapabilities } from "./harness/host-capability-types.js"; import type { AgentHarness } from "./harness/types.js"; import type { AgentRuntimeAuthPlan } from "./runtime-plan/types.js"; @@ -63,6 +73,10 @@ const hasUsableCustomProviderApiKeyMock = vi.fn((..._args: unknown[]) => false); const resolveProviderEntryApiKeyProfileReferenceMock = vi.fn((_params?: unknown): unknown => ({ kind: "none", })); +const preparedRuntimeSnapshotState = vi.hoisted(() => ({ + snapshot: undefined as unknown, + useSnapshotPluginRegistry: false, +})); vi.mock("../llm/stream.js", async () => { const original = await vi.importActual("../llm/stream.js"); @@ -127,6 +141,10 @@ vi.mock("./prepared-model-runtime.js", () => ({ ...workspaceOptions, }); return { + ...(preparedRuntimeSnapshotState.snapshot as object), + ...(preparedRuntimeSnapshotState.useSnapshotPluginRegistry + ? {} + : { pluginRegistry: getActivePluginRegistry() }), agentId: params.agentId, agentDir: params.agentDir, config: params.config, @@ -349,6 +367,7 @@ const DEFAULT_STORE_PATH = "/tmp/sessions.json"; const DEFAULT_QUESTION = "What changed?"; const MATH_QUESTION = "What is 17 * 19?"; const MATH_ANSWER = "323"; +let defaultPluginMetadataSnapshot: ReturnType | undefined; const DEFAULT_USAGE = { input: 1, @@ -659,6 +678,7 @@ function mockOpenAIPlatformProfile(): void { describe("runBtwSideQuestion", () => { afterEach(() => { vi.unstubAllEnvs(); + resetModelGenerationFixtureState(); }); beforeEach(() => { @@ -709,6 +729,15 @@ describe("runBtwSideQuestion", () => { resolveProviderEntryApiKeyProfileReferenceMock.mockReset(); resolveProviderEntryApiKeyProfileReferenceMock.mockReturnValue({ kind: "none" }); clearAgentHarnesses(); + defaultPluginMetadataSnapshot ??= resolvePluginMetadataSnapshot({ + config: {}, + workspaceDir: "/tmp/workspace", + allowCurrent: false, + }); + preparedRuntimeSnapshotState.snapshot = { + metadataSnapshot: defaultPluginMetadataSnapshot, + }; + preparedRuntimeSnapshotState.useSnapshotPluginRegistry = false; readFileMock.mockResolvedValue("mock transcript"); loadTranscriptEventsMock.mockResolvedValue([]); @@ -881,6 +910,81 @@ describe("runBtwSideQuestion", () => { ); }); + it("keeps model, runtime auth, and stream selection on prepared A after current advances to B", async () => { + const cfg = { agents: { entries: { main: { default: true } } } } as never; + const generationA = createModelGenerationFixture({ + config: cfg, + label: "btw-a", + provider: "local-proxy", + requestProvider: "local-proxy", + modelId: "side-model", + }); + const generationB = createModelGenerationFixture({ + config: cfg, + label: "btw-b", + provider: "local-proxy", + requestProvider: "local-proxy", + modelId: "side-model", + }); + preparedRuntimeSnapshotState.snapshot = generationA.preparedModelRuntime; + preparedRuntimeSnapshotState.useSnapshotPluginRegistry = true; + resolveAgentWorkspaceDirMock.mockReturnValue(GENERATION_WORKSPACE_DIR); + publishCurrentModelGeneration(generationB); + const runtimeAuthA = vi.fn(async () => ({ apiKey: "runtime-auth-a" })); + const runtimeAuthB = vi.fn(async () => ({ apiKey: "runtime-auth-b" })); + const streamA = vi.fn( + (model: { name?: string }, _context: unknown, options?: { apiKey?: string }) => { + const apiKey = options?.apiKey; + const resolvedApiKey = + apiKey && looksLikeSecretSentinel(apiKey) ? resolveSecretSentinel(apiKey) : apiKey; + return makeAsyncEvents([ + createDoneEvent(`${model.name ?? "missing model"} / ${resolvedApiKey} / Stream A`), + ]); + }, + ); + const streamB = vi.fn(() => + makeAsyncEvents([createDoneEvent("Generation B / runtime-auth-b / Stream B")]), + ); + const activeGenerationRegistry = () => + getPluginRuntimeGenerationRegistry() ?? getActivePluginRegistry(); + resolveModelWithRegistryMock.mockImplementation(() => { + const snapshot = getCurrentPluginMetadataSnapshot({ + config: cfg, + workspaceDir: GENERATION_WORKSPACE_DIR, + }); + const label = snapshot === generationA.metadataSnapshot ? "A" : "B"; + return { + provider: "local-proxy", + id: "side-model", + name: `Generation ${label}`, + api: "openai-responses", + baseUrl: `https://generation-${label.toLowerCase()}.example.test/v1`, + }; + }); + prepareProviderRuntimeAuthMock.mockImplementation(async () => + activeGenerationRegistry() === generationA.pluginRegistry + ? await runtimeAuthA() + : await runtimeAuthB(), + ); + registerProviderStreamForModelMock.mockImplementation(() => + activeGenerationRegistry() === generationA.pluginRegistry ? streamA : streamB, + ); + + await expect( + runSideQuestion({ cfg, provider: "local-proxy", model: "side-model" }), + ).resolves.toEqual({ text: "Generation A / runtime-auth-a / Stream A" }); + expect(runtimeAuthA).toHaveBeenCalledOnce(); + expect(runtimeAuthB).not.toHaveBeenCalled(); + expect(streamA).toHaveBeenCalledOnce(); + expect(streamA).toHaveBeenCalledWith( + expect.objectContaining({ name: "Generation A" }), + expect.any(Object), + expect.any(Object), + ); + expect(streamB).not.toHaveBeenCalled(); + expect(streamSimpleMock).not.toHaveBeenCalled(); + }); + it("routes Codex-selected BTW questions through the harness side-question hook", async () => { const supports = vi.fn(supportsPreparedOpenAIAuth); const codexSideQuestionMock = registerCodexSideQuestionHarness({ @@ -1525,14 +1629,16 @@ describe("runBtwSideQuestion", () => { expect(result).toEqual({ text: "Copilot fallback answer." }); expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledOnce(); - expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith({ - provider: "github-copilot", - modelId: "gpt-4o", - config: expect.any(Object), - agentId: "main", - sessionKey: DEFAULT_SESSION_KEY, - workspaceDir: "/tmp/workspace", - }); + expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "github-copilot", + modelId: "gpt-4o", + config: expect.any(Object), + agentId: "main", + sessionKey: DEFAULT_SESSION_KEY, + workspaceDir: "/tmp/workspace", + }), + ); expect(streamSimpleMock).toHaveBeenCalledOnce(); }); @@ -1774,6 +1880,13 @@ describe("runBtwSideQuestion", () => { }); requireApiKeyMock.mockReturnValueOnce("claude-cli-access"); resolveSessionAuthProfileOverrideMock.mockResolvedValueOnce(undefined); + resolveModelAsyncMock.mockResolvedValueOnce({ + model: { + provider: DEFAULT_PROVIDER, + id: DEFAULT_MODEL, + api: "anthropic-messages", + }, + }); mockDoneAnswer("Claude CLI answer."); const result = await runSideQuestion(); @@ -1787,10 +1900,12 @@ describe("runBtwSideQuestion", () => { externalCliProviderIds: ["claude-cli"], allowKeychainPrompt: false, }); - expectRecordFields(mockArg(getApiKeyForModelMock, 0, 0), { - profileId: "anthropic:claude-cli", - store: claudeAuthStore, - }); + expect(getApiKeyForModelMock).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: "anthropic:claude-cli", + store: claudeAuthStore, + }), + ); }); it("rematerializes the direct model when automatic auth rotates to a SecretRef backup", async () => { diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 98a5e649dc2e..8f42c8dc8c20 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -20,6 +20,7 @@ import type { TextContent, } from "../llm/types.js"; import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js"; +import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js"; import { isModelSelectionLocked } from "../sessions/model-overrides.js"; import { prepareSystemAgentRunAdmission } from "./admitted-run-context.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; @@ -506,6 +507,7 @@ async function resolveRuntimeModel(params: { modelId: params.model, modelRegistry, cfg, + workspaceDir, }); if (!model) { throw new Error(`Unknown model: ${params.provider}/${params.model}`); @@ -741,677 +743,688 @@ export async function runBtwSideQuestion( // request that omits it can never match one. ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true as const } : {}), }); - const sessionAgentId = - preparedModelRuntime.agentId ?? - resolveSessionAgentId({ sessionKey: params.sessionKey, config: preparedModelRuntime.config }); - const workspaceDir = - preparedModelRuntime.workspaceDir ?? - resolveAgentWorkspaceDir(preparedModelRuntime.config, sessionAgentId); - const preparedModelRef = preparedModelRuntimeConfigsMatch(preparedModelRuntime.config, params.cfg) - ? { provider: params.provider, model: params.model } - : resolveSessionModelRef(preparedModelRuntime.config, params.sessionEntry, sessionAgentId); - // BTW policy, model selection, directories, auth, and catalog must come from one generation. - // A reload may have committed while the command waited for its transcript/session lookup. - // Rebind every later policy/auth/dispatch read to the generation returned above. - params = { - ...params, - cfg: preparedModelRuntime.config, - agentDir: preparedModelRuntime.agentDir, - provider: preparedModelRef.provider, - model: preparedModelRef.model, - }; - const preparedHarnesses = new Map(); - const prepareHarness = async ( - provider: string, - modelId: string, - modelProvider?: AgentHarnessPreparedModelProvider, - ): Promise => { - const agentHarnessId = isModelSelectionLocked(params.sessionEntry) - ? params.sessionEntry.agentHarnessId - : undefined; - const agentHarnessRuntimeOverride = agentHarnessId - ? undefined - : resolveSessionRuntimeOverrideForProvider({ - provider, - entry: params.sessionEntry, - cfg: params.cfg, - }); - const selectedHarnessId = agentHarnessId ?? agentHarnessRuntimeOverride ?? "configured"; - const key = [ - `${provider}/${modelId}/${selectedHarnessId}`, - modelProvider?.api ?? "", - modelProvider?.baseUrl ?? "", - modelProvider?.requestTransportOverrides ?? "", - modelProvider?.runtimePolicy?.compatibleIds.join(",") ?? "", - modelProvider?.preparedAuth?.source ?? "", - modelProvider?.preparedAuth?.mode ?? "", - modelProvider?.preparedAuth?.requirement ?? "", - ].join("\0"); - const cached = preparedHarnesses.get(key); - if (cached) { - return cached; - } - await ensureSelectedAgentHarnessPlugin({ - provider, - modelId, - config: params.cfg, - agentId: sessionAgentId, - sessionKey: params.sessionKey, - workspaceDir, - ...(agentHarnessId ? { agentHarnessId } : {}), - ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), - pluginRegistry: preparedModelRuntime.pluginRegistry!, - }); - const selectionParams = { - provider, - modelId, - config: params.cfg, - agentId: sessionAgentId, - sessionKey: params.sessionKey, - ...(agentHarnessId ? { agentHarnessId } : {}), - ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), + return await withPluginRuntimeGenerationScope(preparedModelRuntime, async () => { + const sessionAgentId = + preparedModelRuntime.agentId ?? + resolveSessionAgentId({ sessionKey: params.sessionKey, config: preparedModelRuntime.config }); + const workspaceDir = + preparedModelRuntime.workspaceDir ?? + resolveAgentWorkspaceDir(preparedModelRuntime.config, sessionAgentId); + const preparedModelRef = preparedModelRuntimeConfigsMatch( + preparedModelRuntime.config, + params.cfg, + ) + ? { provider: params.provider, model: params.model } + : resolveSessionModelRef(preparedModelRuntime.config, params.sessionEntry, sessionAgentId); + // BTW policy, model selection, directories, auth, and catalog must come from one generation. + // A reload may have committed while the command waited for its transcript/session lookup. + // Rebind every later policy/auth/dispatch read to the generation returned above. + params = { + ...params, + cfg: preparedModelRuntime.config, + agentDir: preparedModelRuntime.agentDir, + provider: preparedModelRef.provider, + model: preparedModelRef.model, }; - const harness = modelProvider - ? selectAgentHarnessForPreparedModelProviders({ - ...selectionParams, - modelProviders: [modelProvider], - }) - : selectAgentHarness(selectionParams); - preparedHarnesses.set(key, harness); - return harness; - }; - const harness = await prepareHarness(params.provider, params.model); - let runtimeSelection: Awaited> | undefined; - const resolveRuntimeSelection = async () => { - if (!runtimeSelection) { - runtimeSelection = await resolveRuntimeModel({ - cfg: params.cfg, - provider: params.provider, - model: params.model, - agentId: sessionAgentId, - agentDir: params.agentDir, - workspaceDir, - sessionEntry: params.sessionEntry, - sessionStore: params.sessionStore, - sessionKey: params.sessionKey, - storePath: params.storePath, - isNewSession: params.isNewSession, - harnessId: harness.id, - harnessAuthBootstrap: harness.authBootstrap, - preparedModelRuntime, - }); - } - return runtimeSelection; - }; - type BtwHarnessSideQuestionDispatch = - | { kind: "handled"; payload: ReplyPayload } - | { - kind: "openclaw"; - harness: AgentHarness; - runtime: Awaited>; - resolvedAttempt: Awaited>; - }; - let preparedOpenClawFallback: - | Extract - | undefined; - const runHarnessSideQuestion = async ( - selectedHarness: AgentHarness, - runtime: Awaited>, - routeFinalized = false, - ): Promise => { - const toolsAllow = resolvePluginHarnessPolicyToolsAllow({ - config: params.cfg, - sessionId, - sessionKey: params.sessionKey, - sandboxSessionKey: params.sandboxSessionKey, - agentId: sessionAgentId, - provider: runtime.model.provider, - modelId: runtime.model.id, - messageProvider: params.messageProvider, - messageChannel: params.messageChannel, - spawnedBy: params.spawnedBy, - groupId: params.groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, - agentAccountId: params.agentAccountId, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - }); - const authProfileStoreSelection = - selectedHarness.id === harness.id + const preparedHarnesses = new Map(); + const prepareHarness = async ( + provider: string, + modelId: string, + modelProvider?: AgentHarnessPreparedModelProvider, + ): Promise => { + const agentHarnessId = isModelSelectionLocked(params.sessionEntry) + ? params.sessionEntry.agentHarnessId + : undefined; + const agentHarnessRuntimeOverride = agentHarnessId ? undefined - : resolveBtwAuthProfileStore({ + : resolveSessionRuntimeOverrideForProvider({ + provider, + entry: params.sessionEntry, cfg: params.cfg, + }); + const selectedHarnessId = agentHarnessId ?? agentHarnessRuntimeOverride ?? "configured"; + const key = [ + `${provider}/${modelId}/${selectedHarnessId}`, + modelProvider?.api ?? "", + modelProvider?.baseUrl ?? "", + modelProvider?.requestTransportOverrides ?? "", + modelProvider?.runtimePolicy?.compatibleIds.join(",") ?? "", + modelProvider?.preparedAuth?.source ?? "", + modelProvider?.preparedAuth?.mode ?? "", + modelProvider?.preparedAuth?.requirement ?? "", + ].join("\0"); + const cached = preparedHarnesses.get(key); + if (cached) { + return cached; + } + await ensureSelectedAgentHarnessPlugin({ + provider, + modelId, + config: params.cfg, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + workspaceDir, + ...(agentHarnessId ? { agentHarnessId } : {}), + ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), + pluginRegistry: preparedModelRuntime.pluginRegistry!, + }); + const selectionParams = { + provider, + modelId, + config: params.cfg, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + ...(agentHarnessId ? { agentHarnessId } : {}), + ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), + }; + const harness = modelProvider + ? selectAgentHarnessForPreparedModelProviders({ + ...selectionParams, + modelProviders: [modelProvider], + }) + : selectAgentHarness(selectionParams); + preparedHarnesses.set(key, harness); + return harness; + }; + const harness = await prepareHarness(params.provider, params.model); + let runtimeSelection: Awaited> | undefined; + const resolveRuntimeSelection = async () => { + if (!runtimeSelection) { + runtimeSelection = await resolveRuntimeModel({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + agentId: sessionAgentId, + agentDir: params.agentDir, + workspaceDir, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + storePath: params.storePath, + isNewSession: params.isNewSession, + harnessId: harness.id, + harnessAuthBootstrap: harness.authBootstrap, + preparedModelRuntime, + }); + } + return runtimeSelection; + }; + type BtwHarnessSideQuestionDispatch = + | { kind: "handled"; payload: ReplyPayload } + | { + kind: "openclaw"; + harness: AgentHarness; + runtime: Awaited>; + resolvedAttempt: Awaited>; + }; + let preparedOpenClawFallback: + | Extract + | undefined; + const runHarnessSideQuestion = async ( + selectedHarness: AgentHarness, + runtime: Awaited>, + routeFinalized = false, + ): Promise => { + const toolsAllow = resolvePluginHarnessPolicyToolsAllow({ + config: params.cfg, + sessionId, + sessionKey: params.sessionKey, + sandboxSessionKey: params.sandboxSessionKey, + agentId: sessionAgentId, + provider: runtime.model.provider, + modelId: runtime.model.id, + messageProvider: params.messageProvider, + messageChannel: params.messageChannel, + spawnedBy: params.spawnedBy, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + agentAccountId: params.agentAccountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + const authProfileStoreSelection = + selectedHarness.id === harness.id + ? undefined + : resolveBtwAuthProfileStore({ + cfg: params.cfg, + provider: runtime.model.provider, + modelId: runtime.model.id, + agentId: sessionAgentId, + agentDir: params.agentDir, + workspaceDir, + authProfileId: runtime.authProfileId, + authProfileIdSource: runtime.authProfileIdSource, + }); + const runtimeAuthPreparation = authProfileStoreSelection + ? prepareAgentRuntimeAuth({ provider: runtime.model.provider, modelId: runtime.model.id, - agentId: sessionAgentId, - agentDir: params.agentDir, + modelApi: runtime.model.api, + modelBaseUrl: runtime.model.baseUrl, + config: params.cfg, + env: process.env, workspaceDir, - authProfileId: runtime.authProfileId, - authProfileIdSource: runtime.authProfileIdSource, + authProfileStore: authProfileStoreSelection.store, + sessionAuthProfileId: + authProfileStoreSelection.ignoreAutoPreferredProfile && + runtime.authProfileIdSource !== "user" + ? undefined + : runtime.authProfileId, + sessionAuthProfileSource: runtime.authProfileIdSource, + harnessId: selectedHarness.id, + harnessRuntime: selectedHarness.id, + harnessAuthBootstrap: selectedHarness.authBootstrap, + }) + : runtime.runtimeAuthPreparation; + const selectedAuthProfileStore = authProfileStoreSelection?.store ?? runtime.authProfileStore; + const implicitHarnessAuthPlan = + selectedHarness.authBootstrap === "harness" && + runtimeAuthPreparation.attempts.length === 1 && + runtimeAuthPreparation.attempts[0]?.kind === "implicit" && + runtimeAuthPreparation.attempts[0].plan.harnessAuthProvider + ? runtimeAuthPreparation.attempts[0].plan + : undefined; + // A native harness owns this deferred auth decision. Resolving it through + // OpenClaw would incorrectly require a host credential before handoff. + const resolvedAttempt = implicitHarnessAuthPlan + ? { plan: implicitHarnessAuthPlan, model: runtime.model } + : await resolveBtwPreparedRuntimeAuth({ + preparation: runtimeAuthPreparation, + model: runtime.model, + provider: runtime.model.provider, + modelId: runtime.model.id, + preparedModelRuntime, + authStorage: runtime.authStorage, + modelRegistry: runtime.modelRegistry, + authProfileStore: selectedAuthProfileStore, }); - const runtimeAuthPreparation = authProfileStoreSelection - ? prepareAgentRuntimeAuth({ - provider: runtime.model.provider, - modelId: runtime.model.id, - modelApi: runtime.model.api, - modelBaseUrl: runtime.model.baseUrl, - config: params.cfg, - env: process.env, - workspaceDir, - authProfileStore: authProfileStoreSelection.store, - sessionAuthProfileId: - authProfileStoreSelection.ignoreAutoPreferredProfile && - runtime.authProfileIdSource !== "user" - ? undefined - : runtime.authProfileId, - sessionAuthProfileSource: runtime.authProfileIdSource, - harnessId: selectedHarness.id, - harnessRuntime: selectedHarness.id, - harnessAuthBootstrap: selectedHarness.authBootstrap, - }) - : runtime.runtimeAuthPreparation; - const selectedAuthProfileStore = authProfileStoreSelection?.store ?? runtime.authProfileStore; - const implicitHarnessAuthPlan = - selectedHarness.authBootstrap === "harness" && - runtimeAuthPreparation.attempts.length === 1 && - runtimeAuthPreparation.attempts[0]?.kind === "implicit" && - runtimeAuthPreparation.attempts[0].plan.harnessAuthProvider - ? runtimeAuthPreparation.attempts[0].plan - : undefined; - // A native harness owns this deferred auth decision. Resolving it through - // OpenClaw would incorrectly require a host credential before handoff. - const resolvedAttempt = implicitHarnessAuthPlan - ? { plan: implicitHarnessAuthPlan, model: runtime.model } - : await resolveBtwPreparedRuntimeAuth({ - preparation: runtimeAuthPreparation, - model: runtime.model, - provider: runtime.model.provider, - modelId: runtime.model.id, - preparedModelRuntime, - authStorage: runtime.authStorage, - modelRegistry: runtime.modelRegistry, - authProfileStore: selectedAuthProfileStore, - }); - const runtimeAuthPlan = resolvedAttempt.plan; - const runtimeModel = resolvedAttempt.model; - const finalizedHarness = await prepareHarness(runtimeModel.provider, runtimeModel.id, { - api: runtimeModel.api, - baseUrl: runtimeModel.baseUrl, - ...resolveAgentHarnessPreparedRouteSupport(runtimeAuthPlan), - preparedAuth: resolveAgentHarnessPreparedAuthSupport({ plan: runtimeAuthPlan }), - }); - if (finalizedHarness.id !== selectedHarness.id) { - if (routeFinalized) { - throw new Error("Agent harness selection changed after route materialization."); - } - return runHarnessSideQuestion( - finalizedHarness, - { - ...runtime, - model: runtimeModel, - runtimeAuthPreparation, - authProfileStore: selectedAuthProfileStore, - }, - true, - ); - } - if (!selectedHarness.runSideQuestion) { - if (selectedHarness.id !== "openclaw" || !("auth" in resolvedAttempt)) { - throw new Error( - `Selected agent harness "${selectedHarness.id}" does not support /btw side questions.`, + const runtimeAuthPlan = resolvedAttempt.plan; + const runtimeModel = resolvedAttempt.model; + const finalizedHarness = await prepareHarness(runtimeModel.provider, runtimeModel.id, { + api: runtimeModel.api, + baseUrl: runtimeModel.baseUrl, + ...resolveAgentHarnessPreparedRouteSupport(runtimeAuthPlan), + preparedAuth: resolveAgentHarnessPreparedAuthSupport({ plan: runtimeAuthPlan }), + }); + if (finalizedHarness.id !== selectedHarness.id) { + if (routeFinalized) { + throw new Error("Agent harness selection changed after route materialization."); + } + return runHarnessSideQuestion( + finalizedHarness, + { + ...runtime, + model: runtimeModel, + runtimeAuthPreparation, + authProfileStore: selectedAuthProfileStore, + }, + true, ); } - return { - kind: "openclaw", - harness: selectedHarness, - runtime: { - ...runtime, - model: runtimeModel, - authProfileId: runtimeAuthPlan.forwardedAuthProfileId, - authProfileIdSource: runtimeAuthPlan.forwardedAuthProfileSource, - authProfileStore: selectedAuthProfileStore, - runtimeAuthPreparation, - }, - resolvedAttempt, - }; - } - const resolvedApiKey = - runtimeAuthPlan.modelRoute?.authRequirement === "api-key" && "auth" in resolvedAttempt - ? resolvedAttempt.auth.apiKey?.trim() - : undefined; - const sideRunId = params.authorityRunId; - const sandbox = - (await resolveSessionPlacementSandbox({ - agentId: sessionAgentId, - config: params.cfg, - sessionId, - sessionKey: params.sessionKey, - workspaceDir, - })) ?? - (await resolveSandboxContext({ - config: params.cfg, - sessionKey: params.sandboxSessionKey ?? params.sessionKey ?? sessionId, - workspaceDir, - })); - const preparedRunAdmission = prepareSystemAgentRunAdmission( - params.cfg, - sideRunId, - sessionAgentId, - "btw.side-question", - ); - const admittedRunContext = await preparedRunAdmission.admit("plugin-harness"); - try { - const { model: _sideModel, authorityRunId: _authorityRunId, ...hostAttempt } = params; - const host = createAgentHarnessHostCapabilities({ - attempt: { - ...hostAttempt, - admittedRunContext, - config: params.cfg, + if (!selectedHarness.runSideQuestion) { + if (selectedHarness.id !== "openclaw" || !("auth" in resolvedAttempt)) { + throw new Error( + `Selected agent harness "${selectedHarness.id}" does not support /btw side questions.`, + ); + } + return { + kind: "openclaw", + harness: selectedHarness, + runtime: { + ...runtime, + model: runtimeModel, + authProfileId: runtimeAuthPlan.forwardedAuthProfileId, + authProfileIdSource: runtimeAuthPlan.forwardedAuthProfileSource, + authProfileStore: selectedAuthProfileStore, + runtimeAuthPreparation, + }, + resolvedAttempt, + }; + } + const resolvedApiKey = + runtimeAuthPlan.modelRoute?.authRequirement === "api-key" && "auth" in resolvedAttempt + ? resolvedAttempt.auth.apiKey?.trim() + : undefined; + const sideRunId = params.authorityRunId; + const sandbox = + (await resolveSessionPlacementSandbox({ agentId: sessionAgentId, + config: params.cfg, sessionId, sessionKey: params.sessionKey, - sandbox, workspaceDir, - runId: sideRunId, - currentMessagingTarget: params.messageTo, - currentThreadTs: - params.messageThreadId === undefined ? undefined : String(params.messageThreadId), - }, - pluginId: resolveAgentHarnessOwnerPluginId(selectedHarness), - }); - const sideParams = { - ...hostAttempt, - hostCapabilities: host.capabilities, - sandbox, - provider: runtimeModel.provider, - model: runtimeModel.id, - runtimeModel, - preparedModelRuntime, - preparedRuntimeAuth: { - plan: runtimeAuthPlan, - authProfileStore: scopeAuthProfileStoreToPreparedPlan( - selectedAuthProfileStore, - runtimeAuthPlan, - ), - authStorage: runtime.authStorage, - modelRegistry: runtime.modelRegistry, - ...(resolvedApiKey - ? { - resolvedApiKey: unwrapSecretSentinelsForProviderEgress( - resolvedApiKey, - "BTW harness handoff", - ), - } - : {}), - }, - sessionId, - sessionFile, - agentId: sessionAgentId, - workspaceDir, - ...(toolsAllow ? { toolsAllow } : {}), - authProfileId: - runtimeAuthPlan.modelRoute?.authRequirement === "api-key" - ? undefined - : runtimeAuthPlan.forwardedAuthProfileId, - opts: { ...params.opts, runId: sideRunId }, - authProfileIdSource: - runtimeAuthPlan.modelRoute?.authRequirement === "api-key" - ? undefined - : runtimeAuthPlan.forwardedAuthProfileSource, - }; - let result: Awaited>>; + })) ?? + (await resolveSandboxContext({ + config: params.cfg, + sessionKey: params.sandboxSessionKey ?? params.sessionKey ?? sessionId, + workspaceDir, + })); + const preparedRunAdmission = prepareSystemAgentRunAdmission( + params.cfg, + sideRunId, + sessionAgentId, + "btw.side-question", + ); + const admittedRunContext = await preparedRunAdmission.admit("plugin-harness"); try { - result = await selectedHarness.runSideQuestion(sideParams); + const { model: _sideModel, authorityRunId: _authorityRunId, ...hostAttempt } = params; + const host = createAgentHarnessHostCapabilities({ + attempt: { + ...hostAttempt, + admittedRunContext, + config: params.cfg, + agentId: sessionAgentId, + sessionId, + sessionKey: params.sessionKey, + sandbox, + workspaceDir, + runId: sideRunId, + currentMessagingTarget: params.messageTo, + currentThreadTs: + params.messageThreadId === undefined ? undefined : String(params.messageThreadId), + }, + pluginId: resolveAgentHarnessOwnerPluginId(selectedHarness), + }); + const sideParams = { + ...hostAttempt, + hostCapabilities: host.capabilities, + sandbox, + provider: runtimeModel.provider, + model: runtimeModel.id, + runtimeModel, + preparedModelRuntime, + preparedRuntimeAuth: { + plan: runtimeAuthPlan, + authProfileStore: scopeAuthProfileStoreToPreparedPlan( + selectedAuthProfileStore, + runtimeAuthPlan, + ), + authStorage: runtime.authStorage, + modelRegistry: runtime.modelRegistry, + ...(resolvedApiKey + ? { + resolvedApiKey: unwrapSecretSentinelsForProviderEgress( + resolvedApiKey, + "BTW harness handoff", + ), + } + : {}), + }, + sessionId, + sessionFile, + agentId: sessionAgentId, + workspaceDir, + ...(toolsAllow ? { toolsAllow } : {}), + authProfileId: + runtimeAuthPlan.modelRoute?.authRequirement === "api-key" + ? undefined + : runtimeAuthPlan.forwardedAuthProfileId, + opts: { ...params.opts, runId: sideRunId }, + authProfileIdSource: + runtimeAuthPlan.modelRoute?.authRequirement === "api-key" + ? undefined + : runtimeAuthPlan.forwardedAuthProfileSource, + }; + let result: Awaited>>; + try { + result = await selectedHarness.runSideQuestion(sideParams); + } finally { + host.close(); + } + return { kind: "handled", payload: { text: result.text } }; } finally { - host.close(); + preparedRunAdmission.close(); } - return { kind: "handled", payload: { text: result.text } }; - } finally { - preparedRunAdmission.close(); + }; + if (harness.runSideQuestion) { + const dispatch = await runHarnessSideQuestion(harness, await resolveRuntimeSelection()); + if (dispatch.kind === "handled") { + return dispatch.payload; + } + preparedOpenClawFallback = dispatch; } - }; - if (harness.runSideQuestion) { - const dispatch = await runHarnessSideQuestion(harness, await resolveRuntimeSelection()); - if (dispatch.kind === "handled") { - return dispatch.payload; + if (harness.id === "codex" && !harness.runSideQuestion) { + throw new Error( + `Selected agent harness "${harness.id}" does not support /btw side questions.`, + ); } - preparedOpenClawFallback = dispatch; - } - if (harness.id === "codex" && !harness.runSideQuestion) { - throw new Error(`Selected agent harness "${harness.id}" does not support /btw side questions.`); - } - const activeRunSnapshot = getActiveEmbeddedRunSnapshot(sessionId); - const imageLimits = resolveImageSanitizationLimits(params.cfg); - let messages: Message[] = []; - let inFlightPrompt: string | undefined; - if (Array.isArray(activeRunSnapshot?.messages) && activeRunSnapshot.messages.length > 0) { - messages = await toSimpleContextMessages({ - messages: activeRunSnapshot.messages, - imageLimits, - }); - inFlightPrompt = activeRunSnapshot.inFlightPrompt; - } else if (activeRunSnapshot) { - inFlightPrompt = activeRunSnapshot.inFlightPrompt; - } - if (messages.length === 0) { - messages = await toSimpleContextMessages({ - messages: await readBtwTranscriptMessages({ - agentId: sessionAgentId, - sessionFile, - sessionId, - sessionKey: params.sessionKey, - storePath: params.storePath, - snapshotLeafId: activeRunSnapshot?.transcriptLeafId, - }), - imageLimits, - }); - } - if (messages.length === 0 && !inFlightPrompt?.trim()) { - throw new Error("No active session context."); - } + const activeRunSnapshot = getActiveEmbeddedRunSnapshot(sessionId); + const imageLimits = resolveImageSanitizationLimits(params.cfg); + let messages: Message[] = []; + let inFlightPrompt: string | undefined; + if (Array.isArray(activeRunSnapshot?.messages) && activeRunSnapshot.messages.length > 0) { + messages = await toSimpleContextMessages({ + messages: activeRunSnapshot.messages, + imageLimits, + }); + inFlightPrompt = activeRunSnapshot.inFlightPrompt; + } else if (activeRunSnapshot) { + inFlightPrompt = activeRunSnapshot.inFlightPrompt; + } + if (messages.length === 0) { + messages = await toSimpleContextMessages({ + messages: await readBtwTranscriptMessages({ + agentId: sessionAgentId, + sessionFile, + sessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + snapshotLeafId: activeRunSnapshot?.transcriptLeafId, + }), + imageLimits, + }); + } + if (messages.length === 0 && !inFlightPrompt?.trim()) { + throw new Error("No active session context."); + } - const fallbackPolicy = resolveAvailableAgentHarnessPolicy({ - provider: params.provider, - modelId: params.model, - config: params.cfg, - agentId: sessionAgentId, - sessionKey: params.sessionKey, - }); - const fallbackRuntime = fallbackPolicy.runtime.trim(); - const sessionAuthProfileId = params.sessionEntry.authProfileOverride?.trim() || undefined; - const sessionAuthProfileSource = resolveReturnedAuthProfileSource( - params.sessionEntry, - sessionAuthProfileId, - ); - const cliProviderFromSessionAuth = sessionAuthProfileId - ? resolveCliRuntimeExecutionProvider({ - provider: params.provider, - cfg: params.cfg, - agentId: sessionAgentId, - modelId: params.model, - authProfileId: sessionAuthProfileId, - })?.trim() - : undefined; - const cliProviderFromAuthOrder = - !sessionAuthProfileId || sessionAuthProfileSource === "auto" + const fallbackPolicy = resolveAvailableAgentHarnessPolicy({ + provider: params.provider, + modelId: params.model, + config: params.cfg, + agentId: sessionAgentId, + sessionKey: params.sessionKey, + }); + const fallbackRuntime = fallbackPolicy.runtime.trim(); + const sessionAuthProfileId = params.sessionEntry.authProfileOverride?.trim() || undefined; + const sessionAuthProfileSource = resolveReturnedAuthProfileSource( + params.sessionEntry, + sessionAuthProfileId, + ); + const cliProviderFromSessionAuth = sessionAuthProfileId ? resolveCliRuntimeExecutionProvider({ provider: params.provider, cfg: params.cfg, agentId: sessionAgentId, modelId: params.model, + authProfileId: sessionAuthProfileId, })?.trim() : undefined; - const resolvedCliProvider = cliProviderFromSessionAuth ?? cliProviderFromAuthOrder; - const cliProvider = - resolvedCliProvider ?? - (isCliRuntimeAliasForProvider({ - runtime: fallbackRuntime, - provider: params.provider, - cfg: params.cfg, - }) - ? fallbackRuntime - : undefined); - if (cliProvider) { - return runCliBtwSideQuestion({ - cfg: params.cfg, - model: params.model, - question: params.question, - sessionId, - sessionFile, - sessionEntry: params.sessionEntry, - sessionKey: params.sessionKey, - sessionAgentId, - workspaceDir, - cliProvider, - authProfileId: cliProviderFromSessionAuth ? sessionAuthProfileId : undefined, - resolvedThinkLevel: params.resolvedThinkLevel, - messages, - inFlightPrompt, - opts: params.opts, - authorityRunId: params.authorityRunId, - messageChannel: params.messageChannel, - messageProvider: params.messageProvider, - currentChannelId: params.currentChannelId, - }); - } - - const initialOpenClawFallback = preparedOpenClawFallback; - const runtimeSelectionForHarness = - initialOpenClawFallback?.runtime ?? (await resolveRuntimeSelection()); - // Model resolution can canonicalize a legacy provider alias, so reselect against the resolved - // provider/model instead of reusing the raw route's selection. - const runtimeHarness = - initialOpenClawFallback?.harness ?? - (await prepareHarness( - runtimeSelectionForHarness.model.provider, - runtimeSelectionForHarness.model.id, - )); - if (runtimeHarness.runSideQuestion) { - const dispatch = await runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness); - if (dispatch.kind === "handled") { - return dispatch.payload; - } - preparedOpenClawFallback = dispatch; - } - if (runtimeHarness.id === "codex" && !runtimeHarness.runSideQuestion) { - throw new Error( - `Selected agent harness "${runtimeHarness.id}" does not support /btw side questions.`, - ); - } - - const finalizedOpenClawFallback = preparedOpenClawFallback; - const effectiveRuntimeSelection = - finalizedOpenClawFallback?.runtime ?? runtimeSelectionForHarness; - const { authStorage, model, modelRegistry, authProfileStore, runtimeAuthPreparation } = - effectiveRuntimeSelection; - const resolvedAttempt = - finalizedOpenClawFallback?.resolvedAttempt ?? - (await resolveBtwPreparedRuntimeAuth({ - preparation: runtimeAuthPreparation, - model, - provider: model.provider, - modelId: model.id, - preparedModelRuntime, - authStorage, - modelRegistry, - authProfileStore, - })); - const apiKeyInfo = resolvedAttempt.auth; - const resolvedRuntimeAuthPlan = resolvedAttempt.plan; - const resolvedAuthProfileId = resolvedRuntimeAuthPlan.forwardedAuthProfileId; - let runtimeModel = resolvedAttempt.model; - let apiKey = - apiKeyInfo.mode === "aws-sdk" && !apiKeyInfo.apiKey - ? undefined - : requireApiKey(apiKeyInfo, runtimeModel.provider); - if (apiKey) { - const preparedAuth = protectPreparedProviderRuntimeAuth({ - provider: runtimeModel.provider, - preparedAuth: await prepareProviderRuntimeAuth({ - provider: runtimeModel.provider, - config: params.cfg, + const cliProviderFromAuthOrder = + !sessionAuthProfileId || sessionAuthProfileSource === "auto" + ? resolveCliRuntimeExecutionProvider({ + provider: params.provider, + cfg: params.cfg, + agentId: sessionAgentId, + modelId: params.model, + })?.trim() + : undefined; + const resolvedCliProvider = cliProviderFromSessionAuth ?? cliProviderFromAuthOrder; + const cliProvider = + resolvedCliProvider ?? + (isCliRuntimeAliasForProvider({ + runtime: fallbackRuntime, + provider: params.provider, + cfg: params.cfg, + }) + ? fallbackRuntime + : undefined); + if (cliProvider) { + return runCliBtwSideQuestion({ + cfg: params.cfg, + model: params.model, + question: params.question, + sessionId, + sessionFile, + sessionEntry: params.sessionEntry, + sessionKey: params.sessionKey, + sessionAgentId, workspaceDir, - env: process.env, - context: { + cliProvider, + authProfileId: cliProviderFromSessionAuth ? sessionAuthProfileId : undefined, + resolvedThinkLevel: params.resolvedThinkLevel, + messages, + inFlightPrompt, + opts: params.opts, + authorityRunId: params.authorityRunId, + messageChannel: params.messageChannel, + messageProvider: params.messageProvider, + currentChannelId: params.currentChannelId, + }); + } + + const initialOpenClawFallback = preparedOpenClawFallback; + const runtimeSelectionForHarness = + initialOpenClawFallback?.runtime ?? (await resolveRuntimeSelection()); + // Model resolution can canonicalize a legacy provider alias, so reselect against the resolved + // provider/model instead of reusing the raw route's selection. + const runtimeHarness = + initialOpenClawFallback?.harness ?? + (await prepareHarness( + runtimeSelectionForHarness.model.provider, + runtimeSelectionForHarness.model.id, + )); + if (runtimeHarness.runSideQuestion) { + const dispatch = await runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness); + if (dispatch.kind === "handled") { + return dispatch.payload; + } + preparedOpenClawFallback = dispatch; + } + if (runtimeHarness.id === "codex" && !runtimeHarness.runSideQuestion) { + throw new Error( + `Selected agent harness "${runtimeHarness.id}" does not support /btw side questions.`, + ); + } + + const finalizedOpenClawFallback = preparedOpenClawFallback; + const effectiveRuntimeSelection = + finalizedOpenClawFallback?.runtime ?? runtimeSelectionForHarness; + const { authStorage, model, modelRegistry, authProfileStore, runtimeAuthPreparation } = + effectiveRuntimeSelection; + const resolvedAttempt = + finalizedOpenClawFallback?.resolvedAttempt ?? + (await resolveBtwPreparedRuntimeAuth({ + preparation: runtimeAuthPreparation, + model, + provider: model.provider, + modelId: model.id, + preparedModelRuntime, + authStorage, + modelRegistry, + authProfileStore, + })); + const apiKeyInfo = resolvedAttempt.auth; + const resolvedRuntimeAuthPlan = resolvedAttempt.plan; + const resolvedAuthProfileId = resolvedRuntimeAuthPlan.forwardedAuthProfileId; + let runtimeModel = resolvedAttempt.model; + let apiKey = + apiKeyInfo.mode === "aws-sdk" && !apiKeyInfo.apiKey + ? undefined + : requireApiKey(apiKeyInfo, runtimeModel.provider); + if (apiKey) { + const preparedAuth = protectPreparedProviderRuntimeAuth({ + provider: runtimeModel.provider, + preparedAuth: await prepareProviderRuntimeAuth({ + provider: runtimeModel.provider, config: params.cfg, - agentDir: params.agentDir, workspaceDir, env: process.env, - provider: runtimeModel.provider, - modelId: runtimeModel.id, - model: runtimeModel, - apiKey: unwrapSecretSentinelsForProviderEgress(apiKey, "provider runtime auth exchange"), - authMode: apiKeyInfo.mode, - profileId: resolvedAuthProfileId, - }, - }), - }); - runtimeModel = applyPreparedRuntimeAuthToModel(runtimeModel, preparedAuth); - if (preparedAuth?.apiKey) { - apiKey = preparedAuth.apiKey; - } - } - runtimeModel = applySecretRefHeaderSentinels(runtimeModel, params.cfg); - const modelRegistryRuntime = getModelRegistryRuntime(modelRegistry); - - // Use the provider's own stream fn so providers like Ollama (which build - // `/api/chat` or `/v1/chat/completions` paths based on api mode) construct - // URLs correctly. Without this, streamSimple hits the provider's baseUrl - // directly and 404s on endpoints like Ollama Cloud (#68336). - const providerStreamFn = registerProviderStreamForModel({ - model: runtimeModel, - cfg: params.cfg, - agentDir: params.agentDir, - workspaceDir, - env: process.env, - apiRegistry: modelRegistryRuntime.apiRegistry, - }); - const streamFn = resolveEmbeddedAgentStreamFn({ - llmRuntime: modelRegistryRuntime.llmRuntime, - currentStreamFn: modelRegistryRuntime.llmRuntime.streamSimple, - providerStreamFn, - sessionId, - signal: params.opts?.abortSignal, - model: runtimeModel, - resolvedApiKey: apiKey, - authProfileId: resolvedAuthProfileId, - }); - - const chunker = - params.opts?.onBlockReply && params.blockReplyChunking - ? new EmbeddedBlockChunker(params.blockReplyChunking) - : undefined; - let emittedBlocks = 0; - let blockEmitChain: Promise = Promise.resolve(); - let answerText = ""; - let reasoningText = ""; - let assistantStarted = false; - let sawTextEvent = false; - - const emitBlockChunk = async (text: string) => { - const trimmed = text.trim(); - if (!trimmed || !params.opts?.onBlockReply) { - return; - } - emittedBlocks += 1; - blockEmitChain = blockEmitChain.then(async () => { - await params.opts?.onBlockReply?.({ - text, - btw: { question: params.question }, + context: { + config: params.cfg, + agentDir: params.agentDir, + workspaceDir, + env: process.env, + provider: runtimeModel.provider, + modelId: runtimeModel.id, + model: runtimeModel, + apiKey: unwrapSecretSentinelsForProviderEgress( + apiKey, + "provider runtime auth exchange", + ), + authMode: apiKeyInfo.mode, + profileId: resolvedAuthProfileId, + }, + }), }); + runtimeModel = applyPreparedRuntimeAuthToModel(runtimeModel, preparedAuth); + if (preparedAuth?.apiKey) { + apiKey = preparedAuth.apiKey; + } + } + runtimeModel = applySecretRefHeaderSentinels(runtimeModel, params.cfg); + const modelRegistryRuntime = getModelRegistryRuntime(modelRegistry); + + // Use the provider's own stream fn so providers like Ollama (which build + // `/api/chat` or `/v1/chat/completions` paths based on api mode) construct + // URLs correctly. Without this, streamSimple hits the provider's baseUrl + // directly and 404s on endpoints like Ollama Cloud (#68336). + const providerStreamFn = registerProviderStreamForModel({ + model: runtimeModel, + cfg: params.cfg, + agentDir: params.agentDir, + workspaceDir, + env: process.env, + apiRegistry: modelRegistryRuntime.apiRegistry, }); - await blockEmitChain; - }; - - const stream = await streamWithPayloadPatch( - streamFn, - runtimeModel, - { - systemPrompt: buildBtwSystemPrompt(), - messages: [ - ...messages, - { - role: "user", - content: [ - { - type: "text", - text: buildBtwQuestionPrompt(params.question, inFlightPrompt), - }, - ], - timestamp: Date.now(), - }, - ], - }, - { - apiKey, - // BTW is intentionally a lightweight side question path. Keep provider - // reasoning off so we reliably receive answer text instead of thinking-only output. - reasoning: undefined, + const streamFn = resolveEmbeddedAgentStreamFn({ + llmRuntime: modelRegistryRuntime.llmRuntime, + currentStreamFn: modelRegistryRuntime.llmRuntime.streamSimple, + providerStreamFn, + sessionId, signal: params.opts?.abortSignal, - }, - (payloadObj) => { - // BTW is intentionally tool-less. Some OpenAI-compatible providers reject - // the empty tools arrays injected for generic tool-history replay. - if (Array.isArray(payloadObj.tools) && payloadObj.tools.length === 0) { - delete payloadObj.tools; + model: runtimeModel, + resolvedApiKey: apiKey, + authProfileId: resolvedAuthProfileId, + }); + + const chunker = + params.opts?.onBlockReply && params.blockReplyChunking + ? new EmbeddedBlockChunker(params.blockReplyChunking) + : undefined; + let emittedBlocks = 0; + let blockEmitChain: Promise = Promise.resolve(); + let answerText = ""; + let reasoningText = ""; + let assistantStarted = false; + let sawTextEvent = false; + const flushAtTextEnd = params.resolvedBlockStreamingBreak === "text_end"; + + const emitBlockChunk = async (text: string) => { + const trimmed = text.trim(); + if (!trimmed || !params.opts?.onBlockReply) { + return; } - }, - ); + emittedBlocks += 1; + blockEmitChain = blockEmitChain.then(async () => { + await params.opts?.onBlockReply?.({ + text, + btw: { question: params.question }, + }); + }); + await blockEmitChain; + }; - let finalEvent: - | Extract - | Extract - | undefined; + const stream = await streamWithPayloadPatch( + streamFn, + runtimeModel, + { + systemPrompt: buildBtwSystemPrompt(), + messages: [ + ...messages, + { + role: "user", + content: [ + { + type: "text", + text: buildBtwQuestionPrompt(params.question, inFlightPrompt), + }, + ], + timestamp: Date.now(), + }, + ], + }, + { + apiKey, + // BTW is intentionally a lightweight side question path. Keep provider + // reasoning off so we reliably receive answer text instead of thinking-only output. + reasoning: undefined, + signal: params.opts?.abortSignal, + }, + (payloadObj) => { + // BTW is intentionally tool-less. Some OpenAI-compatible providers reject + // the empty tools arrays injected for generic tool-history replay. + if (Array.isArray(payloadObj.tools) && payloadObj.tools.length === 0) { + delete payloadObj.tools; + } + }, + ); - for await (const event of stream) { - finalEvent = event.type === "done" || event.type === "error" ? event : finalEvent; + let finalEvent: + | Extract + | Extract + | undefined; - if (!assistantStarted && (event.type === "text_start" || event.type === "start")) { - assistantStarted = true; - await params.opts?.onAssistantMessageStart?.(); + for await (const event of stream) { + finalEvent = event.type === "done" || event.type === "error" ? event : finalEvent; + + if (!assistantStarted && (event.type === "text_start" || event.type === "start")) { + assistantStarted = true; + await params.opts?.onAssistantMessageStart?.(); + } + + if (event.type === "text_delta") { + sawTextEvent = true; + answerText += event.delta; + chunker?.append(event.delta); + if (chunker && flushAtTextEnd) { + chunker.drain({ force: false, emit: (chunk) => void emitBlockChunk(chunk) }); + } + continue; + } + + if (event.type === "text_end" && chunker && flushAtTextEnd) { + chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) }); + continue; + } + + if (event.type === "thinking_delta") { + reasoningText += event.delta; + if (params.resolvedReasoningLevel !== "off") { + await params.opts?.onReasoningStream?.({ text: reasoningText, isReasoning: true }); + } + continue; + } + + if (event.type === "thinking_end" && params.resolvedReasoningLevel !== "off") { + await params.opts?.onReasoningEnd?.(); + } } - if (event.type === "text_delta") { - sawTextEvent = true; - answerText += event.delta; - chunker?.append(event.delta); - if (chunker && params.resolvedBlockStreamingBreak === "text_end") { - chunker.drain({ force: false, emit: (chunk) => void emitBlockChunk(chunk) }); - } - continue; - } - - if (event.type === "text_end" && chunker && params.resolvedBlockStreamingBreak === "text_end") { + if (chunker && params.resolvedBlockStreamingBreak !== "text_end" && chunker.hasBuffered()) { chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) }); - continue; + } + await blockEmitChain; + + if (finalEvent?.type === "error") { + const message = collectTextContent(finalEvent.error.content); + throw new Error(message || finalEvent.error.errorMessage || "BTW failed."); } - if (event.type === "thinking_delta") { - reasoningText += event.delta; - if (params.resolvedReasoningLevel !== "off") { - await params.opts?.onReasoningStream?.({ text: reasoningText, isReasoning: true }); + const finalMessage = finalEvent?.type === "done" ? finalEvent.message : undefined; + if (finalMessage) { + if (!sawTextEvent) { + answerText = collectTextContent(finalMessage.content); + } + if (!reasoningText) { + collectThinkingContent(finalMessage.content); } - continue; } - if (event.type === "thinking_end" && params.resolvedReasoningLevel !== "off") { - await params.opts?.onReasoningEnd?.(); + const answer = answerText.trim(); + if (!answer) { + throw new Error("No BTW response generated."); } - } - if (chunker && params.resolvedBlockStreamingBreak !== "text_end" && chunker.hasBuffered()) { - chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) }); - } - await blockEmitChain; - - if (finalEvent?.type === "error") { - const message = collectTextContent(finalEvent.error.content); - throw new Error(message || finalEvent.error.errorMessage || "BTW failed."); - } - - const finalMessage = finalEvent?.type === "done" ? finalEvent.message : undefined; - if (finalMessage) { - if (!sawTextEvent) { - answerText = collectTextContent(finalMessage.content); + if (emittedBlocks > 0) { + return undefined; } - if (!reasoningText) { - collectThinkingContent(finalMessage.content); - } - } - const answer = answerText.trim(); - if (!answer) { - throw new Error("No BTW response generated."); - } - - if (emittedBlocks > 0) { - return undefined; - } - - return { text: answer }; + return { text: answer }; + }); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/embedded-agent-runner/model.configured-fallback.ts b/src/agents/embedded-agent-runner/model.configured-fallback.ts index 6ebb2d5c2f1b..c8f454e4dd8f 100644 --- a/src/agents/embedded-agent-runner/model.configured-fallback.ts +++ b/src/agents/embedded-agent-runner/model.configured-fallback.ts @@ -33,10 +33,7 @@ import { resolveProviderRequestTimeoutMs, resolveProviderTransport, } from "./model.provider-hooks.js"; -import { - resolveBundledStaticCatalogModel, - type ManifestModelCatalogProviderAliasMetadata, -} from "./model.static-catalog.js"; +import type { ManifestModelCatalogProviderAliasMetadata } from "./model.static-catalog.js"; export function buildConfiguredFallbackModel(params: { provider: string; @@ -45,6 +42,7 @@ export function buildConfiguredFallbackModel(params: { agentDir?: string; manifestAlias: ManifestModelCatalogProviderAliasMetadata; providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps; + getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined; workspaceDir?: string; runtimeHooks?: ProviderRuntimeHooks; }): Model | undefined { @@ -55,13 +53,7 @@ export function buildConfiguredFallbackModel(params: { if (!hasConfiguredFallbackSurface({ providerConfig, configuredModel, modelId })) { return undefined; } - const staticCatalogModel = resolveBundledStaticCatalogModel({ - provider, - modelId, - cfg, - workspaceDir, - includeRuntimeDiscovery: true, - }) as StaticCatalogFallbackModel | undefined; + const staticCatalogModel = params.getStaticCatalogModel?.(); const metadataModel = configuredModel ?? staticCatalogModel; const fallbackMediaInput = mergeModelMediaInput( staticCatalogModel?.mediaInput, diff --git a/src/agents/embedded-agent-runner/model.configured-overrides.ts b/src/agents/embedded-agent-runner/model.configured-overrides.ts index 813c83c5238c..63173ddb7465 100644 --- a/src/agents/embedded-agent-runner/model.configured-overrides.ts +++ b/src/agents/embedded-agent-runner/model.configured-overrides.ts @@ -1,6 +1,5 @@ import { asOptionalRecord as readModelParams } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import type { ModelCompatConfig, ModelMediaInputConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { Api, Model } from "../../llm/types.js"; import type { PluginMetadataSnapshotOwnerMaps } from "../../plugins/plugin-metadata-snapshot.types.js"; @@ -38,17 +37,9 @@ import { resolveProviderRequestTimeoutMs, resolveProviderTransport, } from "./model.provider-hooks.js"; -import { - resolveBundledStaticCatalogModel, - type ManifestModelCatalogProviderAliasMetadata, -} from "./model.static-catalog.js"; +import type { ManifestModelCatalogProviderAliasMetadata } from "./model.static-catalog.js"; -export type StaticCatalogFallbackModel = Model & { - compat?: ModelCompatConfig; - contextTokens?: number; - params?: Record; - mediaInput?: ModelMediaInputConfig; -}; +export type StaticCatalogFallbackModel = ProviderRuntimeModel; export function shouldSuppressConfiguredModel(params: { provider: string; @@ -332,6 +323,7 @@ export function applyConfiguredProviderOverrides(params: { preferDiscoveredModelMetadata?: boolean; preferDiscoveredTransport?: boolean; staticCatalogModel?: StaticCatalogFallbackModel; + getStaticCatalogModel?: () => ProviderRuntimeModel | undefined; workspaceDir?: string; }): ProviderRuntimeModel { const { providerConfig, modelId } = params; @@ -397,15 +389,7 @@ export function applyConfiguredProviderOverrides(params: { ? findConfiguredProviderModel(providerConfig, params.provider, discoveredModel.id) : undefined); const configuredStaticCatalogModel = - configuredModel && - (params.staticCatalogModel ?? - (resolveBundledStaticCatalogModel({ - provider: params.provider, - modelId, - cfg: params.cfg, - workspaceDir: params.workspaceDir, - includeRuntimeDiscovery: true, - }) as StaticCatalogFallbackModel | undefined)); + configuredModel && (params.staticCatalogModel ?? params.getStaticCatalogModel?.()); const metadataOverrideModel = params.preferDiscoveredModelMetadata && isModelsAddMetadataModel({ model: configuredModel }) ? undefined diff --git a/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts b/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts index 8f18fd4590c8..f639d9f161ef 100644 --- a/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts +++ b/src/agents/embedded-agent-runner/model.forward-compat.errors-and-overrides.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelProviderConfig, OpenClawConfig } from "../../config/config.js"; import { discoverModels } from "../agent-model-discovery.js"; +import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js"; import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js"; vi.mock("../../plugins/provider-runtime.js", () => ({ @@ -82,23 +83,42 @@ vi.mock("../model-suppression.js", () => ({ vi.mock("../prepared-model-runtime.js", async () => { const discovery = await import("../agent-model-discovery.js"); + const { createPluginMetadataSnapshot } = + await import("../../config/plugin-auto-enable.test-helpers.js"); const createSnapshot = (input: { agentDir: string; config?: OpenClawConfig; workspaceDir?: string; - }) => ({ - createStores: () => { - const authStorage = discovery.discoverAuthStorage(input.agentDir); - const modelRegistry = discovery.discoverModels(authStorage, input.agentDir, { - ...(input.config ? { config: input.config } : {}), + }) => { + const config = input.config ?? {}; + return { + agentDir: input.agentDir, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + activeProjectKeys: [], + allowGatewaySubagentBinding: false, + config, + authModes: {}, + metadataSnapshot: createPluginMetadataSnapshot({ + config, + manifestRegistry: { plugins: [], diagnostics: [] }, ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - if (!("fork" in modelRegistry)) { - Object.assign(modelRegistry, { fork: () => modelRegistry }); - } - return { authStorage, modelRegistry }; - }, - }); + }), + modelCatalog: { entries: [], routeVariants: [] }, + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores: () => { + const authStorage = discovery.discoverAuthStorage(input.agentDir); + const modelRegistry = discovery.discoverModels(authStorage, input.agentDir, { + ...(input.config ? { config: input.config } : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + if (!("fork" in modelRegistry)) { + Object.assign(modelRegistry, { fork: () => modelRegistry }); + } + return { authStorage, modelRegistry }; + }, + } satisfies PreparedModelRuntimeSnapshot; + }; return { getPreparedModelRuntimeSnapshot: createSnapshot, loadPreparedModelRuntimeSnapshot: async (input: Parameters[0]) => diff --git a/src/agents/embedded-agent-runner/model.generation-scope.test-support.ts b/src/agents/embedded-agent-runner/model.generation-scope.test-support.ts new file mode 100644 index 000000000000..7163ad9c8b17 --- /dev/null +++ b/src/agents/embedded-agent-runner/model.generation-scope.test-support.ts @@ -0,0 +1,155 @@ +import { vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { setCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; +import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; +import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js"; +import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; +import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js"; +import { AuthStorage, ModelRegistry } from "../sessions/index.js"; + +const GENERATION_MODEL_ID = "generation-model"; +const GENERATION_REQUEST_PROVIDER = "generation-alias"; +export const GENERATION_WORKSPACE_DIR = "/tmp/openclaw-model-generation-scope"; + +type ImagePolicy = NonNullable["image"]>; + +export function createModelGenerationFixture(params: { + config: OpenClawConfig; + createStores?: PreparedModelRuntimeSnapshot["createStores"]; + label: string; + modelId?: string; + prepareDynamicModel?: () => Promise; + provider?: string; + requestProvider?: string; + runtimeApi?: ProviderRuntimeModel["api"]; + runtimeBaseUrl?: string; + runtimeImagePolicy?: ImagePolicy; + runtimeAugment?: boolean; + staticImagePolicy?: ImagePolicy; + suppress?: boolean; + withRegistry?: boolean; +}) { + const provider = params.provider ?? `generation-${params.label}`; + const requestProvider = params.requestProvider ?? GENERATION_REQUEST_PROVIDER; + const modelId = params.modelId ?? GENERATION_MODEL_ID; + const staticImagePolicy = params.staticImagePolicy ?? { + maxSidePx: params.label === "a" ? 1_111 : 2_222, + }; + const plugin = { + id: `generation-plugin-${params.label}`, + enabledByDefault: true, + channels: [], + providers: [provider], + cliBackends: [], + skills: [], + hooks: [], + origin: "bundled", + rootDir: `/tmp/generation-plugin-${params.label}`, + source: `/tmp/generation-plugin-${params.label}/index.js`, + manifestPath: `/tmp/generation-plugin-${params.label}/openclaw.plugin.json`, + modelCatalog: { + ...(requestProvider === provider ? {} : { aliases: { [requestProvider]: { provider } } }), + ...(params.runtimeAugment === undefined ? {} : { runtimeAugment: params.runtimeAugment }), + discovery: { [provider]: "static" }, + providers: { + [provider]: { + api: "openai-completions", + baseUrl: `https://${provider}.example.test/v1`, + models: [ + { + id: modelId, + name: `Static ${params.label.toUpperCase()}`, + mediaInput: { image: staticImagePolicy }, + }, + ], + }, + }, + ...(params.suppress ? { suppressions: [{ provider, model: modelId }] } : {}), + }, + } satisfies PluginManifestRecord; + const metadataSnapshot = createPluginMetadataSnapshot({ + config: params.config, + manifestRegistry: { plugins: [plugin], diagnostics: [] }, + workspaceDir: GENERATION_WORKSPACE_DIR, + }); + const pluginRegistry = createEmptyPluginRegistry(); + const resolveDynamicModel = vi.fn(() => ({ + id: modelId, + name: `Runtime ${params.label.toUpperCase()}`, + provider, + api: params.runtimeApi ?? ("openai-completions" as const), + baseUrl: params.runtimeBaseUrl ?? `https://${provider}.example.test/v1`, + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8_192, + maxTokens: 2_048, + ...(params.runtimeImagePolicy ? { mediaInput: { image: params.runtimeImagePolicy } } : {}), + })); + pluginRegistry.providers.push({ + pluginId: plugin.id, + source: plugin.source, + provider: { + id: provider, + label: `Generation ${params.label.toUpperCase()}`, + auth: [], + ...(params.prepareDynamicModel ? { prepareDynamicModel: params.prepareDynamicModel } : {}), + resolveDynamicModel, + }, + }); + const createStores = + params.createStores ?? + (() => { + const authStorage = AuthStorage.inMemory({}); + return { authStorage, modelRegistry: ModelRegistry.inMemory(authStorage) }; + }); + const preparedModelRuntime = { + agentDir: "/tmp/openclaw-model-generation-agent", + workspaceDir: GENERATION_WORKSPACE_DIR, + activeProjectKeys: [], + config: params.config, + authModes: {}, + metadataSnapshot, + ...(params.withRegistry === false ? {} : { pluginRegistry }), + allowGatewaySubagentBinding: false, + modelCatalog: { entries: [], routeVariants: [] }, + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores, + } satisfies PreparedModelRuntimeSnapshot; + return { + metadataSnapshot, + modelId, + pluginRegistry, + preparedModelRuntime, + provider, + requestProvider, + resolveDynamicModel, + runtimeImagePolicy: params.runtimeImagePolicy, + staticImagePolicy, + }; +} + +export function publishCurrentModelGeneration( + generation: ReturnType, +): void { + setCurrentPluginMetadataSnapshot(generation.metadataSnapshot, { + config: generation.preparedModelRuntime.config, + workspaceDir: GENERATION_WORKSPACE_DIR, + }); + setActivePluginRegistry( + generation.pluginRegistry, + `generation-${generation.provider}`, + "default", + GENERATION_WORKSPACE_DIR, + ); +} + +export function resetModelGenerationFixtureState(): void { + setCurrentPluginMetadataSnapshot(undefined); + resetPluginRuntimeStateForTest(); + clearPluginMetadataLifecycleCaches(); +} diff --git a/src/agents/embedded-agent-runner/model.generation-scope.test.ts b/src/agents/embedded-agent-runner/model.generation-scope.test.ts new file mode 100644 index 000000000000..72060808776a --- /dev/null +++ b/src/agents/embedded-agent-runner/model.generation-scope.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js"; +import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; +import { + createModelGenerationFixture, + publishCurrentModelGeneration, + resetModelGenerationFixtureState, +} from "./model.generation-scope.test-support.js"; +import { resolveModel, resolveModelAsync } from "./model.js"; + +async function resolveGeneration(generation: ReturnType) { + const { preparedModelRuntime } = generation; + const stores = preparedModelRuntime.createStores(); + return await resolveModelAsync( + generation.requestProvider, + generation.modelId, + preparedModelRuntime.agentDir, + preparedModelRuntime.config, + { + ...stores, + allowBundledStaticCatalogFallback: true, + preparedModelRuntime, + skipAgentDiscovery: true, + workspaceDir: preparedModelRuntime.workspaceDir, + }, + ); +} + +describe("model runtime generation scope", () => { + beforeEach(() => { + clearPluginMetadataLifecycleCaches(); + }); + + afterEach(() => { + resetModelGenerationFixtureState(); + }); + + it("keeps alias, suppression, static metadata, and runtime hooks on the prepared generation", async () => { + const config = {} satisfies OpenClawConfig; + const generationA = createModelGenerationFixture({ config, label: "a" }); + const generationB = createModelGenerationFixture({ config, label: "b", suppress: true }); + publishCurrentModelGeneration(generationB); + + const result = await resolveGeneration(generationA); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: generationA.provider, + name: "Runtime A", + mediaInput: { image: generationA.staticImagePolicy }, + }); + expect(generationA.resolveDynamicModel).toHaveBeenCalled(); + expect(generationB.resolveDynamicModel).not.toHaveBeenCalled(); + }); + + it("keeps concurrent prepared generations isolated across awaited runtime hooks", async () => { + const config = {} satisfies OpenClawConfig; + let arrivals = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const prepareDynamicModel = async () => { + arrivals += 1; + if (arrivals === 2) { + release(); + } + await gate; + }; + const generationA = createModelGenerationFixture({ + config, + label: "a", + prepareDynamicModel, + }); + const generationB = createModelGenerationFixture({ + config, + label: "b", + prepareDynamicModel, + }); + publishCurrentModelGeneration(generationB); + + const [resultA, resultB] = await Promise.all([ + resolveGeneration(generationA), + resolveGeneration(generationB), + ]); + + expect(resultA.model).toMatchObject({ + provider: generationA.provider, + name: "Runtime A", + mediaInput: { image: generationA.staticImagePolicy }, + }); + expect(resultB.model).toMatchObject({ + provider: generationB.provider, + name: "Runtime B", + mediaInput: { image: generationB.staticImagePolicy }, + }); + }); + + it("keeps metadata-only prepared generations from borrowing current runtime hooks", async () => { + const config = {} satisfies OpenClawConfig; + const generationA = createModelGenerationFixture({ + config, + label: "a", + withRegistry: false, + }); + const generationB = createModelGenerationFixture({ config, label: "b" }); + publishCurrentModelGeneration(generationB); + + const result = await resolveGeneration(generationA); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: generationA.provider, + name: "Static A", + mediaInput: { image: generationA.staticImagePolicy }, + }); + expect(generationB.resolveDynamicModel).not.toHaveBeenCalled(); + }); + + it("keeps synchronous resolution on the exact scoped generation", () => { + const config = {} satisfies OpenClawConfig; + const generationA = createModelGenerationFixture({ config, label: "a" }); + const generationB = createModelGenerationFixture({ config, label: "b" }); + publishCurrentModelGeneration(generationB); + const stores = generationA.preparedModelRuntime.createStores(); + + const result = withPluginRuntimeGenerationScope(generationA.preparedModelRuntime, () => + resolveModel( + generationA.requestProvider, + generationA.modelId, + generationA.preparedModelRuntime.agentDir, + config, + { + ...stores, + workspaceDir: generationA.preparedModelRuntime.workspaceDir, + }, + ), + ); + + expect(result.model).toMatchObject({ + provider: generationA.provider, + name: "Runtime A", + }); + expect(generationB.resolveDynamicModel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/embedded-agent-runner/model.registry-resolution.ts b/src/agents/embedded-agent-runner/model.registry-resolution.ts index f19b7a7c3c2d..ba3127181b09 100644 --- a/src/agents/embedded-agent-runner/model.registry-resolution.ts +++ b/src/agents/embedded-agent-runner/model.registry-resolution.ts @@ -59,7 +59,7 @@ export function resolveExplicitModelWithRegistry(params: { workspaceDir?: string; runtimeHooks?: ProviderRuntimeHooks; preparedInlineProviderModels?: readonly InlineModelEntry[]; - preparedStaticCatalogModel?: StaticCatalogFallbackModel; + getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined; }): ExplicitModelResolution | undefined { const { provider, modelId, modelRegistry, cfg, agentDir, workspaceDir, runtimeHooks } = params; const providerMetadataOwners = getRegistryProviderMetadataOwners(modelRegistry); @@ -91,15 +91,7 @@ export function resolveExplicitModelWithRegistry(params: { ) { return { kind: "suppressed" }; } - const staticCatalogModel = - params.preparedStaticCatalogModel ?? - (resolveBundledStaticCatalogModel({ - provider, - modelId, - cfg, - workspaceDir, - includeRuntimeDiscovery: true, - }) as StaticCatalogFallbackModel | undefined); + const staticCatalogModel = params.getStaticCatalogModel?.(); return { kind: "resolved", source: "configured", @@ -176,6 +168,7 @@ export function resolveExplicitModelWithRegistry(params: { manifestAlias: params.manifestAlias, providerMetadataOwners, runtimeHooks, + getStaticCatalogModel: params.getStaticCatalogModel, workspaceDir, }), runtimeHooks, @@ -272,6 +265,7 @@ function resolvePluginDynamicModelWithRegistry(params: { authProfileMode?: AuthProfileCredential["type"] | "aws-sdk"; preferredProfile?: string; runtimeHooks?: ProviderRuntimeHooks; + getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined; }): Model | undefined { const { provider, modelId, modelRegistry, cfg, agentDir, workspaceDir } = params; const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS; @@ -330,6 +324,7 @@ function resolvePluginDynamicModelWithRegistry(params: { runtimeHooks, workspaceDir, preferDiscoveredModelMetadata, + getStaticCatalogModel: params.getStaticCatalogModel, }); return normalizeResolvedModel({ provider, @@ -342,9 +337,7 @@ function resolvePluginDynamicModelWithRegistry(params: { } export function resolveRuntimePreferredSuppressedModel( - params: ResolveModelWithRegistryParams & { - manifestAlias: ManifestModelCatalogProviderAliasMetadata; - }, + params: ResolveModelWithPreparedRegistryParams, ): Model | undefined { const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS; if (!shouldCompareProviderRuntimeResolvedModel({ ...params, runtimeHooks })) { @@ -430,10 +423,13 @@ type ResolveModelWithRegistryParams = { skipConfiguredFallback?: boolean; }; +type ResolveModelWithPreparedRegistryParams = ResolveModelWithRegistryParams & { + manifestAlias: ManifestModelCatalogProviderAliasMetadata; + getStaticCatalogModel?: () => StaticCatalogFallbackModel | undefined; +}; + export function resolveModelWithPreparedRegistry( - params: ResolveModelWithRegistryParams & { - manifestAlias: ManifestModelCatalogProviderAliasMetadata; - }, + params: ResolveModelWithPreparedRegistryParams, ): Model | undefined { // Competing activated owners leave credentials and transport authority unresolved. // Refuse the route before configured fallbacks can accidentally select either owner. @@ -477,11 +473,27 @@ export function resolveModelWithRegistry( ): Model | undefined { const workspaceDir = params.workspaceDir ?? params.cfg?.agents?.defaults?.workspace; const normalizedRef = normalizeProviderModelRef({ ...params, workspaceDir }); + let staticCatalogResolved = false; + let staticCatalogModel: StaticCatalogFallbackModel | undefined; + const getStaticCatalogModel = () => { + if (!staticCatalogResolved) { + staticCatalogResolved = true; + staticCatalogModel = resolveBundledStaticCatalogModel({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg: params.cfg, + workspaceDir, + includeRuntimeDiscovery: true, + }); + } + return staticCatalogModel; + }; return resolveModelWithPreparedRegistry({ ...params, provider: normalizedRef.provider, modelId: normalizedRef.model, manifestAlias: normalizedRef.manifestAlias, + getStaticCatalogModel, ...(workspaceDir !== undefined ? { workspaceDir } : {}), }); } diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index b8373bae9462..20a9ddd3d141 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -191,7 +191,14 @@ vi.mock("../prepared-model-runtime.js", async () => { Object.assign(modelRegistry, { fork: () => modelRegistry }); } const snapshot = { + agentDir: input.agentDir, ...(workspaceDir ? { workspaceDir } : {}), + activeProjectKeys: [], + config: input.config ?? {}, + authModes: {}, + metadataSnapshot: { plugins: [] }, + allowGatewaySubagentBinding: false, + modelCatalog: { entries: [], routeVariants: [] }, configuredRuntimeModels: preparedSnapshotState.configuredRuntimeModels, inlineProviderModels: preparedSnapshotState.inlineProviderModels, createStores: () => ({ authStorage, modelRegistry }), @@ -1009,6 +1016,18 @@ describe("resolveModel", () => { makeMistralCatalogModel({ input: ["text"] }), ); + const preparedModelRuntime = { + agentDir: "/tmp/agent", + activeProjectKeys: [], + allowGatewaySubagentBinding: false, + config: {}, + authModes: {}, + metadataSnapshot: { plugins: [] } as never, + modelCatalog: { entries: [], routeVariants: [] }, + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores: () => ({ authStorage: {} as never, modelRegistry: {} as never }), + } satisfies PreparedModelRuntimeSnapshot; const result = await resolveModelAsync( "mistral", "mistral-medium-3-5", @@ -1018,7 +1037,7 @@ describe("resolveModel", () => { allowBundledStaticCatalogFallback: true, authStorage: { mocked: true } as never, modelRegistry: { find: vi.fn(() => null) } as never, - preparedModelRuntime: {} as PreparedModelRuntimeSnapshot, + preparedModelRuntime, runtimeHooks: createRuntimeHooks(), skipAgentDiscovery: true, }, @@ -1594,7 +1613,7 @@ describe("resolveModel", () => { }); }); - it("does not use bundled static catalog rows unless the caller opts in", async () => { + it("does not read manifest or provider static rows when bundled fallback is disabled", async () => { const result = await resolveModelAsync( "mistral", "mistral-medium-3-5", @@ -1609,6 +1628,7 @@ describe("resolveModel", () => { expect(result.model).toBeUndefined(); expect(result.error).toBe("Unknown model: mistral/mistral-medium-3-5"); expect(resolveBundledStaticCatalogModelMock).not.toHaveBeenCalled(); + expect(resolveBundledProviderStaticCatalogModelMock).not.toHaveBeenCalled(); expect(discoverAuthStorage).not.toHaveBeenCalled(); expect(discoverModels).not.toHaveBeenCalled(); }); @@ -3056,6 +3076,8 @@ describe("resolveModel", () => { expect(result.model).toBeUndefined(); expect(result.error).toBe("Unknown model: azure-openai-responses/gpt-5.5"); + expect(resolveBundledStaticCatalogModelMock).not.toHaveBeenCalled(); + expect(resolveBundledProviderStaticCatalogModelMock).not.toHaveBeenCalled(); }, ); diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index b33a0eb6701a..9b8c32722088 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { Model } from "../../llm/types.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; +import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js"; import { resolveDefaultAgentDir } from "../agent-scope.js"; import type { AuthProfileCredential } from "../auth-profiles/types.js"; import { resolveLegacyInheritedAuthDir } from "../legacy-inherited-auth-dir.js"; @@ -25,6 +26,7 @@ import { buildConfiguredFallbackModel } from "./model.configured-fallback.js"; import { applyConfiguredProviderOverrides, resolveConfiguredProviderConfig, + type StaticCatalogFallbackModel, } from "./model.configured-overrides.js"; import { DEFAULT_PROVIDER_RUNTIME_HOOKS, @@ -144,45 +146,70 @@ export function resolveModel( `prepared model runtime is not published for synchronous model resolution (${resolvedAgentDir}); use resolveModelAsync before lifecycle publication`, ); } - const workspaceDir = - options?.workspaceDir ?? preparedSnapshot?.workspaceDir ?? derivedWorkspaceDir; - const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); - const preparedStores = preparedSnapshot?.createStores(); - const authStorage = options?.authStorage ?? preparedStores!.authStorage; - const modelRegistry = - options?.modelRegistry ?? - (options?.authStorage - ? preparedStores!.modelRegistry.fork(authStorage) - : preparedStores!.modelRegistry); - const runtimeHooks = resolveRuntimeHooks(options); - const model = resolveModelWithPreparedRegistry({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - modelRegistry, - cfg, - agentDir: resolvedAgentDir, - manifestAlias: normalizedRef.manifestAlias, - workspaceDir, - authProfileId: options?.authProfileId, - authProfileMode: options?.authProfileMode, - preferredProfile: options?.preferredProfile, - runtimeHooks, - }); - if (model) { - return { model, authStorage, modelRegistry }; - } - return { - error: buildUnknownModelError({ + const preparedModelRuntime = preparedSnapshot; + const resolve = () => { + const workspaceDir = + options?.workspaceDir ?? preparedModelRuntime?.workspaceDir ?? derivedWorkspaceDir; + const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); + const preparedStores = + !options?.authStorage || !options?.modelRegistry + ? preparedModelRuntime?.createStores() + : undefined; + const authStorage = options?.authStorage ?? preparedStores!.authStorage; + const modelRegistry = + options?.modelRegistry ?? + (options?.authStorage + ? preparedStores!.modelRegistry.fork(authStorage) + : preparedStores!.modelRegistry); + const runtimeHooks = resolveRuntimeHooks(options); + let staticCatalogResolved = false; + let staticCatalogModel: StaticCatalogFallbackModel | undefined; + const getStaticCatalogModel = () => { + if (!staticCatalogResolved) { + staticCatalogResolved = true; + staticCatalogModel = resolveBundledStaticCatalogModel({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + workspaceDir, + includeRuntimeDiscovery: true, + }); + } + return staticCatalogModel; + }; + const model = resolveModelWithPreparedRegistry({ provider: normalizedRef.provider, modelId: normalizedRef.model, + modelRegistry, cfg, agentDir: resolvedAgentDir, + manifestAlias: normalizedRef.manifestAlias, workspaceDir, + authProfileId: options?.authProfileId, + authProfileMode: options?.authProfileMode, + preferredProfile: options?.preferredProfile, runtimeHooks, - }), - authStorage, - modelRegistry, + getStaticCatalogModel, + }); + if (model) { + return { model, authStorage, modelRegistry }; + } + return { + error: buildUnknownModelError({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + agentDir: resolvedAgentDir, + workspaceDir, + runtimeHooks, + }), + authStorage, + modelRegistry, + }; }; + return preparedModelRuntime + ? withPluginRuntimeGenerationScope(preparedModelRuntime, resolve) + : resolve(); } export async function resolveModelAsync( @@ -203,23 +230,27 @@ export async function resolveModelAsync( options?.workspaceDir, options?.agentId, ); + const explicitPreparedRuntime = options?.preparedModelRuntime; const emptyDiscoveryStores = options?.skipAgentDiscovery && (!options.authStorage || !options.modelRegistry) ? createEmptyAgentDiscoveryStores() : undefined; - const publishedSnapshot = - !emptyDiscoveryStores && (!options?.authStorage || !options?.modelRegistry) - ? resolvePreparedAgentSnapshot( - resolvedAgentDir, - cfg, - options?.workspaceDir, - derivedWorkspaceDir, - options?.agentId, - ) - : undefined; + const needsPreparedSnapshot = + !explicitPreparedRuntime && + !emptyDiscoveryStores && + (!options?.authStorage || !options?.modelRegistry); + const publishedSnapshot = needsPreparedSnapshot + ? resolvePreparedAgentSnapshot( + resolvedAgentDir, + cfg, + options?.workspaceDir, + derivedWorkspaceDir, + options?.agentId, + ) + : undefined; const preparedSnapshot = publishedSnapshot ?? - (!emptyDiscoveryStores && (!options?.authStorage || !options?.modelRegistry) + (needsPreparedSnapshot ? await loadPreparedModelRuntimeSnapshot({ ...(options?.agentId ? { agentId: options.agentId } : {}), agentDir: resolvedAgentDir, @@ -228,238 +259,254 @@ export async function resolveModelAsync( ...(derivedWorkspaceDir ? { workspaceDir: derivedWorkspaceDir } : {}), }) : undefined); - const workspaceDir = - options?.workspaceDir ?? preparedSnapshot?.workspaceDir ?? derivedWorkspaceDir; - const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); - const preparedStores = preparedSnapshot?.createStores(); - const fallbackStores = - emptyDiscoveryStores ?? preparedStores ?? createEmptyAgentDiscoveryStores(); - const authStorage = options?.authStorage ?? fallbackStores.authStorage; - const modelRegistry = - options?.modelRegistry ?? - (options?.authStorage - ? fallbackStores.modelRegistry.fork(authStorage) - : fallbackStores.modelRegistry); - const runtimeHooks = resolveRuntimeHooks(options); // Route-projected cfg owns transport/auth; the snapshot contributes generation facts only. - const preparedModelRuntime = options?.preparedModelRuntime ?? preparedSnapshot; - const preparedStaticCatalogModel = preparedModelRuntime?.configuredRuntimeModels?.find( - ({ modelId: candidateId, provider: rowProvider }) => - staticModelIdMatches({ - candidateId, - rowProvider, - provider: normalizedRef.provider, - modelId: normalizedRef.model, - }), - )?.model; - if (normalizedRef.manifestAlias.ambiguous) { - return { - error: buildUnknownModelError({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - cfg, - agentDir: resolvedAgentDir, - workspaceDir, - runtimeHooks, - }), - authStorage, - modelRegistry, + const preparedModelRuntime = explicitPreparedRuntime ?? preparedSnapshot; + const resolve = async () => { + const workspaceDir = + options?.workspaceDir ?? preparedModelRuntime?.workspaceDir ?? derivedWorkspaceDir; + const normalizedRef = normalizeProviderModelRef({ provider, modelId, cfg, workspaceDir }); + const preparedStores = + !options?.authStorage || !options?.modelRegistry + ? preparedModelRuntime?.createStores() + : undefined; + const fallbackStores = + emptyDiscoveryStores ?? preparedStores ?? createEmptyAgentDiscoveryStores(); + const authStorage = options?.authStorage ?? fallbackStores.authStorage; + const modelRegistry = + options?.modelRegistry ?? + (options?.authStorage + ? fallbackStores.modelRegistry.fork(authStorage) + : fallbackStores.modelRegistry); + const runtimeHooks = resolveRuntimeHooks(options); + let staticCatalogResolved = false; + let staticCatalogModel: StaticCatalogFallbackModel | undefined; + const getManifestStaticCatalogModel = () => { + if (!staticCatalogResolved) { + staticCatalogResolved = true; + staticCatalogModel = + preparedModelRuntime?.configuredRuntimeModels?.find( + ({ modelId: candidateId, provider: rowProvider }) => + staticModelIdMatches({ + candidateId, + rowProvider, + provider: normalizedRef.provider, + modelId: normalizedRef.model, + }), + )?.model ?? + resolveBundledStaticCatalogModel({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + workspaceDir, + includeRuntimeDiscovery: true, + ...(preparedModelRuntime + ? { metadataSnapshot: preparedModelRuntime.metadataSnapshot } + : {}), + }); + } + return staticCatalogModel; }; - } - const explicitModel = resolveExplicitModelWithRegistry({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - modelRegistry, - cfg, - agentDir: resolvedAgentDir, - manifestAlias: normalizedRef.manifestAlias, - workspaceDir, - runtimeHooks, - preparedInlineProviderModels: preparedModelRuntime?.inlineProviderModels, - preparedStaticCatalogModel, - }); - if (explicitModel?.kind === "suppressed") { - const suppressedRuntimeModel = resolveRuntimePreferredSuppressedModel({ + if (normalizedRef.manifestAlias.ambiguous) { + return { + error: buildUnknownModelError({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + agentDir: resolvedAgentDir, + workspaceDir, + runtimeHooks, + }), + authStorage, + modelRegistry, + }; + } + const explicitModel = resolveExplicitModelWithRegistry({ provider: normalizedRef.provider, modelId: normalizedRef.model, modelRegistry, cfg, agentDir: resolvedAgentDir, - ...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}), manifestAlias: normalizedRef.manifestAlias, workspaceDir, - authProfileId: options?.authProfileId, - authProfileMode: options?.authProfileMode, - preferredProfile: options?.preferredProfile, runtimeHooks, + preparedInlineProviderModels: preparedModelRuntime?.inlineProviderModels, + getStaticCatalogModel: getManifestStaticCatalogModel, }); - if (suppressedRuntimeModel) { - return { model: suppressedRuntimeModel, authStorage, modelRegistry }; - } - return { - error: buildUnknownModelError({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - cfg, - agentDir: resolvedAgentDir, - workspaceDir, - runtimeHooks, - }), - authStorage, - modelRegistry, - }; - } - const providerConfig = resolveConfiguredProviderConfig(cfg, normalizedRef.provider); - const authProfile = resolveDynamicModelAuthProfile({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - cfg, - agentDir: resolvedAgentDir, - authProfileId: options?.authProfileId, - authProfileMode: options?.authProfileMode, - preferredProfile: options?.preferredProfile, - }); - const preparedMetadataSnapshot = preparedModelRuntime?.metadataSnapshot; - let staticCatalogLookup: Promise | undefined; - const resolveStaticCatalogModel = async () => { - if (!options?.allowBundledStaticCatalogFallback) { - return undefined; - } - staticCatalogLookup ??= (async () => { - if (preparedStaticCatalogModel) { - return preparedStaticCatalogModel; - } - const manifestModel = resolveBundledStaticCatalogModel({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - cfg, - workspaceDir, - includeRuntimeDiscovery: true, - ...(preparedMetadataSnapshot ? { metadataSnapshot: preparedMetadataSnapshot } : {}), - }); - if (manifestModel) { - return manifestModel; - } - return await resolveBundledProviderStaticCatalogModel({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - cfg, - workspaceDir, - ...(preparedMetadataSnapshot ? { metadataSnapshot: preparedMetadataSnapshot } : {}), - }); - })(); - return await staticCatalogLookup; - }; - const resolveStaticCatalogFallbackModel = async () => { - const catalogModel = await resolveStaticCatalogModel(); - if (!catalogModel) { - return undefined; - } - const overriddenStaticCatalogModel = applyConfiguredProviderOverrides({ - provider: normalizedRef.provider, - discoveredModel: catalogModel, - providerConfig, - modelId: normalizedRef.model, - cfg, - manifestAlias: normalizedRef.manifestAlias, - runtimeHooks, - workspaceDir, - preferDiscoveredModelMetadata: true, - preferDiscoveredTransport: options?.preferBundledStaticCatalogTransport, - staticCatalogModel: catalogModel, - }); - return normalizeResolvedModel({ - provider: normalizedRef.provider, - cfg, - agentDir: resolvedAgentDir, - workspaceDir, - model: overriddenStaticCatalogModel, - runtimeHooks, - }); - }; - const resolveDynamicAttempt = async () => { - await runtimeHooks.prepareProviderDynamicModel({ - provider: normalizedRef.provider, - config: cfg, - workspaceDir, - context: { - config: cfg, - agentDir: resolvedAgentDir, - ...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}), - workspaceDir, + if (explicitModel?.kind === "suppressed") { + const suppressedRuntimeModel = resolveRuntimePreferredSuppressedModel({ provider: normalizedRef.provider, modelId: normalizedRef.model, modelRegistry, - providerConfig, - ...authProfile, - }, - }); - return resolveModelWithPreparedRegistry({ + cfg, + agentDir: resolvedAgentDir, + ...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}), + manifestAlias: normalizedRef.manifestAlias, + workspaceDir, + authProfileId: options?.authProfileId, + authProfileMode: options?.authProfileMode, + preferredProfile: options?.preferredProfile, + runtimeHooks, + getStaticCatalogModel: getManifestStaticCatalogModel, + }); + if (suppressedRuntimeModel) { + return { model: suppressedRuntimeModel, authStorage, modelRegistry }; + } + return { + error: buildUnknownModelError({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + agentDir: resolvedAgentDir, + workspaceDir, + runtimeHooks, + }), + authStorage, + modelRegistry, + }; + } + const providerConfig = resolveConfiguredProviderConfig(cfg, normalizedRef.provider); + const authProfile = resolveDynamicModelAuthProfile({ provider: normalizedRef.provider, modelId: normalizedRef.model, - modelRegistry, cfg, agentDir: resolvedAgentDir, - ...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}), - manifestAlias: normalizedRef.manifestAlias, - workspaceDir, authProfileId: options?.authProfileId, authProfileMode: options?.authProfileMode, preferredProfile: options?.preferredProfile, - runtimeHooks, - ...(options?.allowBundledStaticCatalogFallback ? { skipConfiguredFallback: true } : {}), }); - }; - const providerRuntimeMetadataShouldWin = shouldCompareProviderRuntimeResolvedModel({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - cfg, - agentDir: resolvedAgentDir, - workspaceDir, - runtimeHooks, - }); - let model = - explicitModel?.kind === "resolved" && !providerRuntimeMetadataShouldWin - ? explicitModel.model - : undefined; - model ??= await resolveDynamicAttempt(); - if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) { - model = await resolveStaticCatalogFallbackModel(); - } - if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) { - model = buildConfiguredFallbackModel({ + const preparedMetadataSnapshot = preparedModelRuntime?.metadataSnapshot; + let providerStaticCatalogLookup: Promise | undefined; + const resolveStaticCatalogModel = async () => { + if (!options?.allowBundledStaticCatalogFallback) { + return undefined; + } + return ( + getManifestStaticCatalogModel() ?? + (await (providerStaticCatalogLookup ??= resolveBundledProviderStaticCatalogModel({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + workspaceDir, + ...(preparedMetadataSnapshot ? { metadataSnapshot: preparedMetadataSnapshot } : {}), + }))) + ); + }; + const resolveStaticCatalogFallbackModel = async () => { + const catalogModel = await resolveStaticCatalogModel(); + if (!catalogModel) { + return undefined; + } + const overriddenStaticCatalogModel = applyConfiguredProviderOverrides({ + provider: normalizedRef.provider, + discoveredModel: catalogModel, + providerConfig, + modelId: normalizedRef.model, + cfg, + manifestAlias: normalizedRef.manifestAlias, + runtimeHooks, + workspaceDir, + preferDiscoveredModelMetadata: true, + preferDiscoveredTransport: options?.preferBundledStaticCatalogTransport, + staticCatalogModel: catalogModel, + }); + return normalizeResolvedModel({ + provider: normalizedRef.provider, + cfg, + agentDir: resolvedAgentDir, + workspaceDir, + model: overriddenStaticCatalogModel, + runtimeHooks, + }); + }; + const resolveDynamicAttempt = async () => { + await runtimeHooks.prepareProviderDynamicModel({ + provider: normalizedRef.provider, + config: cfg, + workspaceDir, + context: { + config: cfg, + agentDir: resolvedAgentDir, + ...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}), + workspaceDir, + provider: normalizedRef.provider, + modelId: normalizedRef.model, + modelRegistry, + providerConfig, + ...authProfile, + }, + }); + return resolveModelWithPreparedRegistry({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + modelRegistry, + cfg, + agentDir: resolvedAgentDir, + ...(options?.agentRuntimeId ? { agentRuntimeId: options.agentRuntimeId } : {}), + manifestAlias: normalizedRef.manifestAlias, + workspaceDir, + authProfileId: options?.authProfileId, + authProfileMode: options?.authProfileMode, + preferredProfile: options?.preferredProfile, + runtimeHooks, + getStaticCatalogModel: getManifestStaticCatalogModel, + ...(options?.allowBundledStaticCatalogFallback ? { skipConfiguredFallback: true } : {}), + }); + }; + const providerRuntimeMetadataShouldWin = shouldCompareProviderRuntimeResolvedModel({ provider: normalizedRef.provider, modelId: normalizedRef.model, cfg, agentDir: resolvedAgentDir, - manifestAlias: normalizedRef.manifestAlias, workspaceDir, runtimeHooks, }); - } - if (model && options?.allowBundledStaticCatalogFallback) { - const staticMediaInput = (await resolveStaticCatalogModel())?.mediaInput; - const resolvedMediaInput = (model as ProviderRuntimeModel).mediaInput; - const mediaInput = mergeModelMediaInput(staticMediaInput, resolvedMediaInput); - if (mediaInput) { - model = { ...(model as ProviderRuntimeModel), mediaInput } as typeof model; + let model = + explicitModel?.kind === "resolved" && !providerRuntimeMetadataShouldWin + ? explicitModel.model + : undefined; + model ??= await resolveDynamicAttempt(); + if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) { + model = await resolveStaticCatalogFallbackModel(); } - } - if (model) { - return { model, authStorage, modelRegistry }; - } - return { - error: buildUnknownModelError({ - provider: normalizedRef.provider, - modelId: normalizedRef.model, - cfg, - agentDir: resolvedAgentDir, - workspaceDir, - runtimeHooks, - }), - authStorage, - modelRegistry, + if (!model && !explicitModel && options?.allowBundledStaticCatalogFallback) { + model = buildConfiguredFallbackModel({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + agentDir: resolvedAgentDir, + manifestAlias: normalizedRef.manifestAlias, + workspaceDir, + runtimeHooks, + getStaticCatalogModel: getManifestStaticCatalogModel, + }); + } + if (model && options?.allowBundledStaticCatalogFallback) { + const staticMediaInput = (await resolveStaticCatalogModel())?.mediaInput; + const resolvedMediaInput = (model as ProviderRuntimeModel).mediaInput; + const mediaInput = mergeModelMediaInput(staticMediaInput, resolvedMediaInput); + if (mediaInput) { + model = { ...(model as ProviderRuntimeModel), mediaInput } as typeof model; + } + } + if (model) { + return { model, authStorage, modelRegistry }; + } + return { + error: buildUnknownModelError({ + provider: normalizedRef.provider, + modelId: normalizedRef.model, + cfg, + agentDir: resolvedAgentDir, + workspaceDir, + runtimeHooks, + }), + authStorage, + modelRegistry, + }; }; + return preparedModelRuntime + ? await withPluginRuntimeGenerationScope(preparedModelRuntime, resolve) + : await resolve(); } /** diff --git a/src/agents/model-suppression.test.ts b/src/agents/model-suppression.test.ts index 22401446c5d3..0b34194d3606 100644 --- a/src/agents/model-suppression.test.ts +++ b/src/agents/model-suppression.test.ts @@ -3,6 +3,8 @@ * Verifies plugin manifest suppression rules, cache reuse, and lifecycle clears. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; const mocks = vi.hoisted(() => ({ buildManifestBuiltInModelSuppressionResolver: vi.fn(), @@ -12,8 +14,12 @@ vi.mock("../plugins/manifest-model-suppression.js", () => ({ buildManifestBuiltInModelSuppressionResolver: mocks.buildManifestBuiltInModelSuppressionResolver, })); -import { setCurrentPluginMetadataSnapshotState } from "../plugins/current-plugin-metadata-state.js"; +import { + getCurrentPluginMetadataSnapshot, + setCurrentPluginMetadataSnapshot, +} from "../plugins/current-plugin-metadata-snapshot.js"; import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js"; +import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js"; import { buildShouldSuppressBuiltInModelCore, shouldSuppressBuiltInModelCore, @@ -28,6 +34,7 @@ describe("model suppression", () => { }); afterEach(() => { + setCurrentPluginMetadataSnapshot(undefined); if (originalBundledPluginsDir === undefined) { delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; } else { @@ -101,12 +108,20 @@ describe("model suppression", () => { .mockReturnValueOnce(firstResolver) .mockReturnValueOnce(secondResolver); - setCurrentPluginMetadataSnapshotState({ id: "first" }, undefined); + const firstSnapshot = createPluginMetadataSnapshot({ + config, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + const secondSnapshot = createPluginMetadataSnapshot({ + config, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + setCurrentPluginMetadataSnapshot(firstSnapshot, { config }); expect(shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config })).toBe( false, ); - setCurrentPluginMetadataSnapshotState({ id: "second" }, undefined); + setCurrentPluginMetadataSnapshot(secondSnapshot, { config }); expect(shouldSuppressBuiltInModelCore({ provider: "openai", id: "gpt-5.3", config })).toBe( false, ); @@ -116,6 +131,60 @@ describe("model suppression", () => { expect(secondResolver).toHaveBeenCalledOnce(); }); + it("keeps concurrent scoped suppression resolvers isolated from process-current metadata", async () => { + const config = {} satisfies OpenClawConfig; + const snapshotA = createPluginMetadataSnapshot({ + config, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + const snapshotB = createPluginMetadataSnapshot({ + config, + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + setCurrentPluginMetadataSnapshot(snapshotB, { config }); + mocks.buildManifestBuiltInModelSuppressionResolver.mockImplementation(() => { + const snapshot = getCurrentPluginMetadataSnapshot({ config, env: process.env }); + return () => + snapshot === snapshotA ? { suppress: true, errorMessage: "generation A" } : undefined; + }); + let releaseA!: () => void; + let markAReady!: () => void; + const holdA = new Promise((resolve) => { + releaseA = resolve; + }); + const aReady = new Promise((resolve) => { + markAReady = resolve; + }); + const resultA = withPluginRuntimeGenerationScope( + { config, metadataSnapshot: snapshotA }, + async () => { + const result = shouldSuppressBuiltInModelCore({ + provider: "openai", + id: "generation-model", + config, + }); + markAReady(); + await holdA; + return result; + }, + ); + await aReady; + + const resultB = await withPluginRuntimeGenerationScope( + { config, metadataSnapshot: snapshotB }, + async () => + shouldSuppressBuiltInModelCore({ + provider: "openai", + id: "generation-model", + config, + }), + ); + releaseA(); + + await expect(resultA).resolves.toBe(true); + expect(resultB).toBe(false); + }); + it("refreshes manifest suppression resolver when process env plugin metadata inputs change", () => { const firstResolver = vi.fn().mockReturnValue(undefined); const secondResolver = vi.fn().mockReturnValue(undefined); diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index a07e649865b0..34ef2d846bc9 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -6,7 +6,7 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { getCurrentPluginMetadataSnapshotState } from "../plugins/current-plugin-metadata-state.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { buildManifestBuiltInModelSuppressionResolver } from "../plugins/manifest-model-suppression.js"; import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../plugins/plugin-metadata-lifecycle.js"; @@ -46,7 +46,7 @@ function resolveCachedManifestSuppressionResolver(params: { }); const cwd = process.cwd(); const envFingerprint = resolvePluginMetadataEnvFingerprint(params.env); - const metadataSnapshot = getCurrentPluginMetadataSnapshotState().snapshot; + const metadataSnapshot = getCurrentPluginMetadataSnapshot(params); if ( cached !== undefined && cached.config === params.config && diff --git a/src/agents/tools/image-tool.test-support.ts b/src/agents/tools/image-tool.test-support.ts index 00c51843fa66..d14cd2e9c11a 100644 --- a/src/agents/tools/image-tool.test-support.ts +++ b/src/agents/tools/image-tool.test-support.ts @@ -14,7 +14,6 @@ import type { MediaUnderstandingProvider, } from "../../plugin-sdk/media-understanding.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; -import type { resolveBundledStaticCatalogModel } from "../embedded-agent-runner/model.static-catalog.js"; import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js"; import type { coerceImageAssistantText, @@ -61,7 +60,6 @@ type ImageToolProviderDeps = { describeImagesWithModel: typeof describeImagesWithModel; resolveAutoMediaKeyProviders: typeof resolveAutoMediaKeyProviders; resolveDefaultMediaModel: typeof resolveDefaultMediaModel; - resolveBundledStaticCatalogModel: typeof resolveBundledStaticCatalogModel; resolveModelAsync: ResolveModelAsync; resolveRegisteredMediaUnderstandingProvider(params: { providerId: string; diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index b2d09bb5939c..b08eb6708763 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -8,7 +8,6 @@ import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { createPluginMetadataSnapshot } from "../../config/plugin-auto-enable.test-helpers.js"; import type { ModelDefinitionConfig } from "../../config/types.models.js"; import { encodePngRgba, fillPixel } from "../../media/png-encode.js"; import type { @@ -16,16 +15,15 @@ import type { ImagesDescriptionRequest, MediaUnderstandingProvider, } from "../../plugin-sdk/media-understanding.js"; -import { - getCurrentPluginMetadataSnapshot, - installTemporaryCurrentPluginMetadataSnapshot, -} from "../../plugins/current-plugin-metadata-snapshot.js"; -import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js"; +import { + createModelGenerationFixture, + publishCurrentModelGeneration, + resetModelGenerationFixtureState, +} from "../embedded-agent-runner/model.generation-scope.test-support.js"; import { minimaxUnderstandImage } from "../minimax-vlm.js"; -import type { PreparedModelRuntimeSnapshot } from "../prepared-model-runtime.js"; import { createContainerWorkspaceSandboxFsBridge, createHostSandboxFsBridge, @@ -3191,115 +3189,64 @@ describe("image compression policy", () => { it("keeps runtime augmentation pinned to the prepared plugin generation", async () => { const provider = "prepared-image-provider"; const model = "prepared-image-model"; - const workspaceDir = "/fake/prepared-image-workspace"; const cfg = {} satisfies OpenClawConfig; - const createSnapshot = ( - runtimeAugment: boolean, - imagePolicy: { - maxBytes: number; - preferredSidePx: number; - tokenMode: "detail" | "provider"; - }, - ) => { - const plugin = { - id: "prepared-image-plugin", - enabledByDefault: true, - channels: [], - providers: [provider], - cliBackends: [], - skills: [], - hooks: [], - origin: "bundled", - rootDir: "/fake/prepared-image-plugin", - source: "/fake/prepared-image-plugin/index.js", - manifestPath: "/fake/prepared-image-plugin/openclaw.plugin.json", - modelCatalog: { - runtimeAugment, - discovery: { [provider]: "static" }, - providers: { - [provider]: { - baseUrl: "https://prepared-image.example.test/v1", - api: "openai-completions", - models: [ - { - id: model, - name: "Prepared image model", - mediaInput: { image: imagePolicy }, - }, - ], - }, - }, - }, - } satisfies PluginManifestRecord; - return createPluginMetadataSnapshot({ - config: cfg, - manifestRegistry: { plugins: [plugin], diagnostics: [] }, - workspaceDir, - }); - }; - const preparedSnapshot = createSnapshot(true, { - maxBytes: 1_000_000, - preferredSidePx: 1_280, - tokenMode: "detail", - }); - const currentSnapshot = createSnapshot(false, { - maxBytes: 2_000_000, - preferredSidePx: 2_560, - tokenMode: "provider", - }); - const preparedModelRuntime = { - agentDir: "/fake/prepared-image-agent", - workspaceDir, - activeProjectKeys: [], - allowGatewaySubagentBinding: false, + const generationA = createModelGenerationFixture({ config: cfg, - authModes: {}, - metadataSnapshot: preparedSnapshot, - modelCatalog: { entries: [], routeVariants: [] }, - configuredRuntimeModels: [], - inlineProviderModels: [], - createStores: () => ({ authStorage: {} as never, modelRegistry: {} as never }), - } satisfies PreparedModelRuntimeSnapshot; - const modelModule = await import("../embedded-agent-runner/model.js"); - const resolveModelAsyncSpy = vi.spyOn(modelModule, "resolveModelAsync"); + label: "image-a", + provider, + requestProvider: provider, + modelId: model, + runtimeAugment: true, + staticImagePolicy: { + maxBytes: 1_000_000, + preferredSidePx: 1_280, + tokenMode: "detail", + }, + runtimeImagePolicy: { maxSidePx: 1_440 }, + }); + const generationB = createModelGenerationFixture({ + config: cfg, + label: "image-b", + provider, + requestProvider: provider, + modelId: model, + runtimeAugment: true, + staticImagePolicy: { + maxBytes: 2_000_000, + preferredSidePx: 2_560, + tokenMode: "provider", + }, + runtimeImagePolicy: { maxSidePx: 2_880 }, + }); installImageUnderstandingProviderDeps([], { useDefaultResolveModelAsync: true, }); - const currentLease = installTemporaryCurrentPluginMetadataSnapshot(currentSnapshot, { - config: cfg, - workspaceDir, - }); + publishCurrentModelGeneration(generationB); try { - expect(getCurrentPluginMetadataSnapshot({ config: cfg, workspaceDir })).toBe(currentSnapshot); await expect( testing.resolveImageCompressionPolicy({ cfg, imageModelConfig: { primary: `${provider}/${model}` }, imageCount: 1, - preparedModelRuntime, - workspaceDir, + preparedModelRuntime: generationA.preparedModelRuntime, + workspaceDir: generationA.preparedModelRuntime.workspaceDir, }), ).resolves.toEqual({ imageCount: 1, models: [ { + maxSidePx: 1_440, maxBytes: 1_000_000, preferredSidePx: 1_280, tokenMode: "detail", }, ], }); - expect(resolveModelAsyncSpy).toHaveBeenCalledTimes(2); - expect( - resolveModelAsyncSpy.mock.calls.map((call) => call[4]?.skipProviderRuntimeHooks), - ).toEqual([true, false]); - for (const call of resolveModelAsyncSpy.mock.calls) { - expect(call[4]?.preparedModelRuntime).toBe(preparedModelRuntime); - } + expect(generationA.resolveDynamicModel).toHaveBeenCalled(); + expect(generationB.resolveDynamicModel).not.toHaveBeenCalled(); } finally { - resolveModelAsyncSpy.mockRestore(); - currentLease.release(); + resetModelGenerationFixtureState(); } }); @@ -3343,6 +3290,7 @@ describe("image compression policy", () => { }); it("uses bundled Anthropic media limits without runtime provider hooks", async () => { + installImageUnderstandingProviderDeps([], { useDefaultResolveModelAsync: true }); await expect( testing.resolveImageCompressionPolicy({ cfg: {}, diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index ca24641a3a82..6a5112cffa27 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -35,10 +35,7 @@ import { isManifestPluginAvailableForControlPlane } from "../../plugins/manifest import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { resolveUserPath } from "../../utils.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; -import { - bundledStaticCatalogProviderUsesRuntimeAugment, - resolveBundledStaticCatalogModel, -} from "../embedded-agent-runner/model.static-catalog.js"; +import { bundledStaticCatalogProviderUsesRuntimeAugment } from "../embedded-agent-runner/model.static-catalog.js"; import { isMinimaxVlmProvider } from "../minimax-vlm.js"; import { resolveImageFallbackCandidates, @@ -137,7 +134,6 @@ const imageToolProviderDeps = { describeImagesWithModel, resolveAutoMediaKeyProviders, resolveDefaultMediaModel, - resolveBundledStaticCatalogModel, resolveModelAsync: resolveModelAsyncDefault, resolveRegisteredMediaUnderstandingProvider, resolveImageCompressionPolicy, @@ -201,7 +197,6 @@ const testing = { describeImagesWithModel?: typeof describeImagesWithModel; resolveAutoMediaKeyProviders?: typeof resolveAutoMediaKeyProviders; resolveDefaultMediaModel?: typeof resolveDefaultMediaModel; - resolveBundledStaticCatalogModel?: typeof resolveBundledStaticCatalogModel; resolveModelAsync?: ResolveModelAsync; resolveRegisteredMediaUnderstandingProvider?: typeof resolveRegisteredMediaUnderstandingProvider; resolveImageCompressionPolicy?: typeof resolveImageCompressionPolicy; @@ -219,8 +214,6 @@ const testing = { overrides?.resolveAutoMediaKeyProviders ?? resolveAutoMediaKeyProviders; imageToolProviderDeps.resolveDefaultMediaModel = overrides?.resolveDefaultMediaModel ?? resolveDefaultMediaModel; - imageToolProviderDeps.resolveBundledStaticCatalogModel = - overrides?.resolveBundledStaticCatalogModel ?? resolveBundledStaticCatalogModel; imageToolProviderDeps.resolveModelAsync = overrides?.resolveModelAsync ?? resolveModelAsyncDefault; imageToolProviderDeps.resolveRegisteredMediaUnderstandingProvider = @@ -468,24 +461,6 @@ function mergeImageCompressionPolicies(params: { }; } -function resolveBundledStaticCompressionModelPolicy(params: { - cfg?: OpenClawConfig; - provider: string; - model: string; - workspaceDir?: string; - preparedModelRuntime?: PreparedModelRuntimeSnapshot; -}): ImageCompressionModelPolicy { - const model = imageToolProviderDeps.resolveBundledStaticCatalogModel({ - provider: params.provider, - modelId: params.model, - cfg: params.cfg, - workspaceDir: params.workspaceDir, - includeRuntimeDiscovery: true, - metadataSnapshot: params.preparedModelRuntime?.metadataSnapshot, - }); - return model?.mediaInput?.image ?? {}; -} - function providerUsesRuntimeModelAugment(params: { cfg?: OpenClawConfig; provider: string; @@ -578,14 +553,10 @@ async function resolveCompressionModelPolicy(params: { workspaceDir?: string; preparedModelRuntime?: PreparedModelRuntimeSnapshot; }): Promise { - const configuredStaticPolicy = await resolveCompressionModelPolicyWithHooks({ + const staticPolicy = await resolveCompressionModelPolicyWithHooks({ ...params, skipProviderRuntimeHooks: true, }); - const staticPolicy = mergeImageCompressionPolicies({ - runtimePolicy: resolveBundledStaticCompressionModelPolicy(params), - staticPolicy: configuredStaticPolicy, - }); if ( imageCompressionPolicyHasDimensionLimit(staticPolicy) || !providerUsesRuntimeModelAugment({