From 03e3ef86afd9f505ea9e85014e6caa1e838e79f9 Mon Sep 17 00:00:00 2001 From: zengLingbiao Date: Tue, 16 Jun 2026 08:27:19 +0800 Subject: [PATCH] fix(agents): resolve configured default model in runEmbeddedAgent (fixes #93419) (#93428) * fix(agents): honor configured default model in embedded runs * fix(agents): resolve embedded defaults from runtime config * fix(agents): preserve embedded model routing semantics * test(agents): model current embedded attempts explicitly --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> --- src/agents/embedded-agent-runner.e2e.test.ts | 94 +++++++++++++++++++ .../run.codex-server-error-fallback.test.ts | 1 + ...ss-provider-fallback-error-context.test.ts | 4 + .../run.empty-error-retry.test.ts | 38 ++++---- .../run.incomplete-turn.test.ts | 48 +++++----- src/agents/embedded-agent-runner/run.ts | 28 +++++- 6 files changed, 170 insertions(+), 43 deletions(-) diff --git a/src/agents/embedded-agent-runner.e2e.test.ts b/src/agents/embedded-agent-runner.e2e.test.ts index 0e4cc0a3cd4f..1638f922be88 100644 --- a/src/agents/embedded-agent-runner.e2e.test.ts +++ b/src/agents/embedded-agent-runner.e2e.test.ts @@ -41,6 +41,8 @@ const resolveModelAsyncMock = vi.fn( const ensureOpenClawModelsJsonMock = vi.fn(async () => ({ wrote: false })); const loggerWarnMock = vi.fn(); let refreshRuntimeAuthOnFirstPromptError = false; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let setRuntimeConfigSnapshot: typeof import("../config/config.js").setRuntimeConfigSnapshot; vi.mock("openclaw/plugin-sdk/llm", async () => { const actual = @@ -178,6 +180,7 @@ beforeAll(async () => { vi.useRealTimers(); vi.resetModules(); installRunEmbeddedMocks(); + ({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = await import("../config/config.js")); ({ runEmbeddedAgent } = await import("./embedded-agent-runner/run.js")); ({ SessionManager } = await import("openclaw/plugin-sdk/agent-sessions")); e2eWorkspace = await createEmbeddedAgentRunnerTestWorkspace("openclaw-embedded-agent-"); @@ -190,6 +193,7 @@ afterAll(async () => { }); beforeEach(() => { + clearRuntimeConfigSnapshot(); vi.useRealTimers(); runEmbeddedAttemptMock.mockReset(); disposeSessionMcpRuntimeMock.mockReset(); @@ -321,6 +325,96 @@ function firstRunEmbeddedAttemptParams(): { sessionKey?: string } { } describe("runEmbeddedAgent", () => { + it("uses the configured default model when the caller omits provider and model", async () => { + const sessionFile = nextSessionFile(); + const cfg = { + ...createEmbeddedAgentRunnerOpenAiConfig([]), + agents: { + defaults: { + model: { + primary: "openrouter/global-default", + }, + }, + list: [{ id: "research", model: "openrouter/research-default" }], + }, + }; + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeEmbeddedRunnerAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildEmbeddedRunnerAssistant({ + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + await runEmbeddedAgent({ + sessionId: "configured-default-model", + sessionFile, + workspaceDir, + config: cfg, + agentId: "research", + prompt: "hello", + timeoutMs: 5_000, + agentDir, + runId: nextRunId("configured-default-model"), + enqueue: immediateEnqueue, + }); + + expect(resolveModelAsyncMock).toHaveBeenNthCalledWith( + 1, + "openrouter", + "openrouter/research-default", + agentDir, + cfg, + expect.objectContaining({ skipAgentDiscovery: true }), + ); + }); + + it("uses runtime config for blank public runtime model overrides", async () => { + const sessionFile = nextSessionFile(); + const cfg = { + ...createEmbeddedAgentRunnerOpenAiConfig([]), + agents: { + defaults: { + model: { + primary: "openrouter/runtime-default", + }, + }, + }, + }; + setRuntimeConfigSnapshot(cfg); + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeEmbeddedRunnerAttempt({ + assistantTexts: ["ok"], + lastAssistant: buildEmbeddedRunnerAssistant({ + content: [{ type: "text", text: "ok" }], + }), + }), + ); + + await runEmbeddedAgent({ + sessionId: "runtime-config-default-model", + sessionFile, + workspaceDir, + prompt: "hello", + provider: " ", + model: "", + timeoutMs: 5_000, + agentDir, + runId: nextRunId("runtime-config-default-model"), + enqueue: immediateEnqueue, + }); + + expect(resolveModelAsyncMock).toHaveBeenNthCalledWith( + 1, + "openrouter", + "openrouter/runtime-default", + agentDir, + cfg, + expect.objectContaining({ skipAgentDiscovery: true }), + ); + }); + it("skips models.json generation when dynamic model resolution succeeds", async () => { const sessionFile = nextSessionFile(); const cfg = createEmbeddedAgentRunnerOpenAiConfig([]); diff --git a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts index 5f4e4012cff1..a764f7896904 100644 --- a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts +++ b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test.ts @@ -55,6 +55,7 @@ describe("runEmbeddedAgent Codex server_error fallback handoff", () => { const promise = runEmbeddedAgent({ ...overflowBaseRunParams, runId: "run-codex-server-error-fallback", + agentHarnessRuntimeOverride: "openclaw", config: makeModelFallbackCfg({ agents: { defaults: { diff --git a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts index 7bd49cca9c5e..e5a2da6cae80 100644 --- a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts +++ b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts @@ -131,6 +131,7 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { ...overflowBaseRunParams, runId: "run-cross-provider-fallback-error-context", config: makeCrossProviderFallbackConfig(), + agentHarnessRuntimeOverride: "openclaw", }); await expectDeepseekFallbackError(promise, getLastFormattedAssistant); @@ -165,6 +166,7 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { ...overflowBaseRunParams, runId: "run-compaction-fallback-error-context", config: makeCrossProviderFallbackConfig(), + agentHarnessRuntimeOverride: "openclaw", }); await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); @@ -200,6 +202,7 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { ...overflowBaseRunParams, runId: "run-stale-session-assistant-timeout", config: makeCrossProviderFallbackConfig(), + agentHarnessRuntimeOverride: "openclaw", }); await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); @@ -232,6 +235,7 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { ...overflowBaseRunParams, runId: "run-stale-session-assistant-non-timeout", config: makeCrossProviderFallbackConfig(), + agentHarnessRuntimeOverride: "openclaw", }); expect(mockedIsFailoverAssistantError).toHaveBeenCalledWith(undefined); diff --git a/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts b/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts index f8b3d6ded275..6dc4f0541e06 100644 --- a/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts +++ b/src/agents/embedded-agent-runner/run.empty-error-retry.test.ts @@ -25,31 +25,35 @@ function emptyErrorAttempt( ): EmbeddedRunAttemptResult { // Models can report stopReason=error with no output after tool activity; that // is replay-safe only when the attempt metadata records no side effects. + const assistant = { + role: "assistant", + stopReason: "error", + provider, + model, + content, + usage: { input: 100, output: outputTokens, totalTokens: 100 + outputTokens }, + ...(errorMessage ? { errorMessage } : {}), + } as unknown as NonNullable; return makeAttemptResult({ assistantTexts: [], - lastAssistant: { - role: "assistant", - stopReason: "error", - provider, - model, - content, - usage: { input: 100, output: outputTokens, totalTokens: 100 + outputTokens }, - ...(errorMessage ? { errorMessage } : {}), - } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + lastAssistant: assistant, + currentAttemptAssistant: assistant, }); } function successAttempt(provider: string, model: string): EmbeddedRunAttemptResult { + const assistant = { + role: "assistant", + stopReason: "stop", + provider, + model, + content: [{ type: "text", text: "Done." }], + usage: { input: 100, output: 5, totalTokens: 105 }, + } as unknown as NonNullable; return makeAttemptResult({ assistantTexts: ["Done."], - lastAssistant: { - role: "assistant", - stopReason: "stop", - provider, - model, - content: [{ type: "text", text: "Done." }], - usage: { input: 100, output: 5, totalTokens: 105 }, - } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + lastAssistant: assistant, + currentAttemptAssistant: assistant, }); } diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts index f762b2dcfa88..3e20693de117 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -591,6 +591,14 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { it("records same-model rate-limit retries without a profile-rotation trace", async () => { const rateLimitMessage = "429 rate_limit_exceeded: requests per minute exceeded; Retry-After: 30"; + const rateLimitAssistant = { + role: "assistant", + stopReason: "error", + provider: "openai", + model: "gpt-5.5", + errorMessage: rateLimitMessage, + content: [], + } as unknown as NonNullable; mockedClassifyFailoverReason.mockImplementation((raw) => raw.includes("429") ? "rate_limit" : null, ); @@ -603,14 +611,8 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce( makeAttemptResult({ assistantTexts: [], - lastAssistant: { - role: "assistant", - stopReason: "error", - provider: "openai", - model: "gpt-5.5", - errorMessage: rateLimitMessage, - content: [], - } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + lastAssistant: rateLimitAssistant, + currentAttemptAssistant: rateLimitAssistant, }), ); mockedRunEmbeddedAttempt.mockResolvedValueOnce( @@ -774,23 +776,25 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { it("retries reasoning-only turns when the assistant ended in error", async () => { mockedClassifyFailoverReason.mockReturnValue(null); + const errorAssistant = { + role: "assistant", + stopReason: "error", + provider: "openai", + model: "gpt-5.4", + errorMessage: "provider failed after emitting reasoning", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_error_turn", type: "reasoning" }), + }, + ], + } as unknown as NonNullable; mockedRunEmbeddedAttempt.mockResolvedValueOnce( makeAttemptResult({ assistantTexts: [], - lastAssistant: { - role: "assistant", - stopReason: "error", - provider: "openai", - model: "gpt-5.4", - errorMessage: "provider failed after emitting reasoning", - content: [ - { - type: "thinking", - thinking: "internal reasoning", - thinkingSignature: JSON.stringify({ id: "rs_error_turn", type: "reasoning" }), - }, - ], - } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + lastAssistant: errorAssistant, + currentAttemptAssistant: errorAssistant, }), ); mockedRunEmbeddedAttempt.mockResolvedValueOnce( diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index bf052681051b..f304eb23fe9d 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -8,6 +8,7 @@ import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; +import { getRuntimeConfigSnapshot } from "../../config/config.js"; import { resolveStorePath } from "../../config/sessions.js"; import { updateSessionEntry } from "../../config/sessions/session-accessor.js"; import { ensureContextEnginesInitialized } from "../../context-engine/init.js"; @@ -99,6 +100,7 @@ import { resolveAuthProfileOrder, shouldPreferExplicitConfigApiKeyAuth, } from "../model-auth.js"; +import { resolveDefaultModelForAgent } from "../model-selection.js"; import { resolveThinkingDefault } from "../model-thinking-default.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; import { @@ -529,11 +531,18 @@ function buildHandledReplyPayloads(reply?: ReplyPayload) { export function runEmbeddedAgent( paramsInput: RunEmbeddedAgentParams, ): Promise { + const requestedProvider = normalizeOptionalString(paramsInput.provider); + const requestedModel = normalizeOptionalString(paramsInput.model); + const needsConfiguredDefault = !paramsInput.config && !requestedProvider && !requestedModel; + const config = + paramsInput.config ?? + (needsConfiguredDefault ? (getRuntimeConfigSnapshot() ?? undefined) : undefined); const lifecycleGeneration = paramsInput.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(paramsInput.runId); return withAgentRunLifecycleGeneration(lifecycleGeneration, () => runEmbeddedAgentInternal({ ...paramsInput, + config, lifecycleGeneration, }), ); @@ -749,8 +758,17 @@ async function runEmbeddedAgentInternal( startupStages.mark("runtime-plugins"); notifyExecutionPhase("runtime_plugins"); - let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; - let modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; + const requestedProvider = normalizeOptionalString(params.provider); + const requestedModel = normalizeOptionalString(params.model); + const configuredDefault = + !requestedProvider && !requestedModel + ? resolveDefaultModelForAgent({ + cfg: params.config ?? {}, + agentId: workspaceResolution.agentId, + }) + : undefined; + let provider = requestedProvider ?? configuredDefault?.provider ?? DEFAULT_PROVIDER; + let modelId = requestedModel ?? configuredDefault?.model ?? DEFAULT_MODEL; const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, workspaceResolution.agentId); const normalizedSessionKey = params.sessionKey?.trim(); @@ -2008,11 +2026,13 @@ async function runEmbeddedAgentInternal( if (attempt.contextBudgetStatus) { lastContextBudgetStatus = attempt.contextBudgetStatus; } + // Transcript fallback can outlive a provider or alias transition. + // Reuse it only when it reports the effective model for this attempt. const sessionAssistantForCandidate = !currentAttemptAssistant && !isAssistantForModelRef(sessionLastAssistant, { - provider, - model: modelId, + provider: effectiveModel.provider, + model: effectiveModel.id, }) ? undefined : sessionLastAssistant;