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>
This commit is contained in:
zengLingbiao
2026-06-16 08:27:19 +08:00
committed by GitHub
parent eac3e08cfd
commit 03e3ef86af
6 changed files with 170 additions and 43 deletions
@@ -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([]);
@@ -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: {
@@ -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);
@@ -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<EmbeddedRunAttemptResult["lastAssistant"]>;
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<EmbeddedRunAttemptResult["lastAssistant"]>;
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,
});
}
@@ -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<EmbeddedRunAttemptResult["lastAssistant"]>;
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<EmbeddedRunAttemptResult["lastAssistant"]>;
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(
+24 -4
View File
@@ -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<EmbeddedAgentRunResult> {
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;