mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
* 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:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user