mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-06 06:41:08 +00:00
fix: queue model switches behind busy runs
This commit is contained in:
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
|
||||
- MiniMax/plugins: auto-enable the bundled MiniMax plugin for API-key auth/config so MiniMax image generation and other plugin-owned capabilities load without manual plugin allowlisting. (#57127) Thanks @tars90percent.
|
||||
- Memory/QMD: prefer `--mask` over `--glob` when creating QMD collections so default memory collections keep their intended patterns and stop colliding on restart. (#58643) Thanks @GitZhangChi.
|
||||
- Gateway/HTTP: skip failing HTTP request stages so one broken facade no longer forces every HTTP endpoint to return 500. (#58746) Thanks @yelog
|
||||
- Sessions/model switching: keep `/model` changes queued behind busy runs instead of interrupting the active turn, and retarget queued followups so later work picks up the new model as soon as the current turn finishes.
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ Notes:
|
||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
||||
- On Discord, `/model` and `/models` open an interactive picker with provider and model dropdowns plus a Submit step.
|
||||
- `/model <#>` selects from that picker.
|
||||
- `/model` updates the session selection immediately. If the agent is idle, the next run uses the new model right away. If the agent is busy, the in-flight run finishes first and queued/future work uses the new model after that.
|
||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
||||
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), you must include the provider prefix (example: `/model openrouter/moonshotai/kimi-k2`).
|
||||
|
||||
@@ -146,6 +146,7 @@ Notes:
|
||||
- `/fast` is provider-specific: OpenAI/OpenAI Codex map it to `service_tier=priority` on native Responses endpoints, while direct public Anthropic requests, including OAuth-authenticated traffic sent to `api.anthropic.com`, map it to `service_tier=auto` or `standard_only`. See [OpenAI](/providers/openai) and [Anthropic](/providers/anthropic).
|
||||
- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`.
|
||||
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
|
||||
- `/model` persists the new session model immediately, but it does not interrupt a busy run. The current turn finishes first, then queued or future work uses the updated model.
|
||||
- **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model).
|
||||
- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements.
|
||||
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
|
||||
|
||||
@@ -28,8 +28,6 @@ import {
|
||||
import {
|
||||
hasDifferentLiveSessionModelSelection,
|
||||
LiveSessionModelSwitchError,
|
||||
resolveLiveSessionModelSelection,
|
||||
shouldTrackPersistedLiveSessionModelSelection,
|
||||
consumeLiveSessionModelSwitch,
|
||||
} from "../live-model-switch.js";
|
||||
import {
|
||||
@@ -238,18 +236,6 @@ export async function runEmbeddedPiAgent(
|
||||
authProfileId: preferredProfileId,
|
||||
authProfileIdSource: params.authProfileIdSource,
|
||||
});
|
||||
const resolvePersistedLiveSelection = () =>
|
||||
resolveLiveSessionModelSelection({
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: workspaceResolution.agentId,
|
||||
defaultProvider: provider,
|
||||
defaultModel: modelId,
|
||||
});
|
||||
const shouldTrackPersistedLiveSelection = shouldTrackPersistedLiveSessionModelSelection(
|
||||
resolveCurrentLiveSelection(),
|
||||
resolvePersistedLiveSelection(),
|
||||
);
|
||||
const {
|
||||
advanceAuthProfile,
|
||||
initializeAuthProfile,
|
||||
@@ -457,15 +443,6 @@ export async function runEmbeddedPiAgent(
|
||||
};
|
||||
}
|
||||
runLoopIterations += 1;
|
||||
const nextSelection = shouldTrackPersistedLiveSelection
|
||||
? resolvePersistedLiveSelection()
|
||||
: null;
|
||||
if (hasDifferentLiveSessionModelSelection(resolveCurrentLiveSelection(), nextSelection)) {
|
||||
log.info(
|
||||
`live session model switch detected before attempt for ${params.sessionId}: ${provider}/${modelId} -> ${nextSelection.provider}/${nextSelection.model}`,
|
||||
);
|
||||
throw new LiveSessionModelSwitchError(nextSelection);
|
||||
}
|
||||
const runtimeAuthRetry = authRetryPending;
|
||||
authRetryPending = false;
|
||||
attemptedThinking.add(thinkLevel);
|
||||
@@ -614,23 +591,6 @@ export async function runEmbeddedPiAgent(
|
||||
);
|
||||
throw new LiveSessionModelSwitchError(requestedSelection);
|
||||
}
|
||||
const failedOrAbortedAttempt =
|
||||
aborted || Boolean(promptError) || Boolean(assistantErrorText) || timedOut;
|
||||
const persistedSelection =
|
||||
failedOrAbortedAttempt && shouldTrackPersistedLiveSelection
|
||||
? resolvePersistedLiveSelection()
|
||||
: null;
|
||||
if (
|
||||
failedOrAbortedAttempt &&
|
||||
canRestartForLiveSwitch &&
|
||||
hasDifferentLiveSessionModelSelection(resolveCurrentLiveSelection(), persistedSelection)
|
||||
) {
|
||||
log.info(
|
||||
`live session model switch detected after failed attempt for ${params.sessionId}: ${provider}/${modelId} -> ${persistedSelection.provider}/${persistedSelection.model}`,
|
||||
);
|
||||
throw new LiveSessionModelSwitchError(persistedSelection);
|
||||
}
|
||||
|
||||
// ── Timeout-triggered compaction ──────────────────────────────────
|
||||
// When the LLM times out with high context usage, compact before
|
||||
// retrying to break the death spiral of repeated timeouts.
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "../../agents/agent-scope.js";
|
||||
import { renderExecTargetLabel, resolveExecTarget } from "../../agents/bash-tools.exec-runtime.js";
|
||||
import { resolveFastModeState } from "../../agents/fast-mode.js";
|
||||
import { requestLiveSessionModelSwitch } from "../../agents/live-model-switch.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
withOptions,
|
||||
} from "./directive-handling.shared.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel } from "./directives.js";
|
||||
import { refreshQueuedFollowupSession } from "./queue.js";
|
||||
|
||||
function resolveExecDefaults(params: {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -442,15 +442,16 @@ export async function handleDirectiveOnly(
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
if (modelSelection && modelSelectionUpdated) {
|
||||
requestLiveSessionModelSwitch({
|
||||
sessionEntry,
|
||||
selection: {
|
||||
provider: modelSelection.provider,
|
||||
model: modelSelection.model,
|
||||
authProfileId: profileOverride,
|
||||
authProfileIdSource: profileOverride ? "user" : undefined,
|
||||
},
|
||||
if (modelSelection && modelSelectionUpdated && sessionKey) {
|
||||
// `/model` should retarget queued/future work without interrupting the
|
||||
// active run. Refresh queued followups so they pick up the persisted
|
||||
// selection once the current turn finishes.
|
||||
refreshQueuedFollowupSession({
|
||||
key: sessionKey,
|
||||
nextProvider: modelSelection.provider,
|
||||
nextModel: modelSelection.model,
|
||||
nextAuthProfileId: profileOverride,
|
||||
nextAuthProfileIdSource: profileOverride ? "user" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import { persistInlineDirectives } from "./directive-handling.persist.js";
|
||||
const liveModelSwitchMocks = vi.hoisted(() => ({
|
||||
requestLiveSessionModelSwitch: vi.fn(),
|
||||
}));
|
||||
const queueMocks = vi.hoisted(() => ({
|
||||
refreshQueuedFollowupSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock dependencies for directive handling persistence.
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
@@ -42,6 +45,11 @@ vi.mock("../../agents/live-model-switch.js", () => ({
|
||||
liveModelSwitchMocks.requestLiveSessionModelSwitch(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", () => ({
|
||||
refreshQueuedFollowupSession: (...args: unknown[]) =>
|
||||
queueMocks.refreshQueuedFollowupSession(...args),
|
||||
}));
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/agent";
|
||||
const OPENAI_DATE_PROFILE_ID = "20251001";
|
||||
|
||||
@@ -75,6 +83,7 @@ beforeEach(() => {
|
||||
},
|
||||
]);
|
||||
liveModelSwitchMocks.requestLiveSessionModelSwitch.mockReset().mockReturnValue(false);
|
||||
queueMocks.refreshQueuedFollowupSession.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -507,7 +516,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
expect(result?.text).not.toContain("failed");
|
||||
});
|
||||
|
||||
it("requests a live restart when /model mutates an active session", async () => {
|
||||
it("does not request a live restart when /model mutates an active session", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry = createSessionEntry();
|
||||
|
||||
@@ -518,14 +527,26 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(liveModelSwitchMocks.requestLiveSessionModelSwitch).toHaveBeenCalledWith({
|
||||
sessionEntry,
|
||||
selection: {
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
authProfileId: undefined,
|
||||
authProfileIdSource: undefined,
|
||||
},
|
||||
expect(liveModelSwitchMocks.requestLiveSessionModelSwitch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retargets queued followups when /model mutates session state", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry = createSessionEntry();
|
||||
|
||||
await handleDirectiveOnly(
|
||||
createHandleParams({
|
||||
directives,
|
||||
sessionEntry,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queueMocks.refreshQueuedFollowupSession).toHaveBeenCalledWith({
|
||||
key: sessionKey,
|
||||
nextProvider: "openai",
|
||||
nextModel: "gpt-4o",
|
||||
nextAuthProfileId: undefined,
|
||||
nextAuthProfileIdSource: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,25 +125,54 @@ function refreshQueuedFollowupSessionForFollowupTest(params: {
|
||||
previousSessionId?: string;
|
||||
nextSessionId?: string;
|
||||
nextSessionFile?: string;
|
||||
nextProvider?: string;
|
||||
nextModel?: string;
|
||||
nextAuthProfileId?: string;
|
||||
nextAuthProfileIdSource?: "auto" | "user";
|
||||
}): void {
|
||||
const cleaned = params.key.trim();
|
||||
if (!cleaned || !params.previousSessionId || !params.nextSessionId) {
|
||||
return;
|
||||
}
|
||||
if (params.previousSessionId === params.nextSessionId) {
|
||||
if (!cleaned) {
|
||||
return;
|
||||
}
|
||||
const queue = FOLLOWUP_TEST_QUEUES.get(cleaned);
|
||||
if (!queue) {
|
||||
return;
|
||||
}
|
||||
const shouldRewriteSession =
|
||||
Boolean(params.previousSessionId) &&
|
||||
Boolean(params.nextSessionId) &&
|
||||
params.previousSessionId !== params.nextSessionId;
|
||||
const shouldRewriteSelection =
|
||||
typeof params.nextProvider === "string" ||
|
||||
typeof params.nextModel === "string" ||
|
||||
Object.hasOwn(params, "nextAuthProfileId") ||
|
||||
Object.hasOwn(params, "nextAuthProfileIdSource");
|
||||
if (!shouldRewriteSession && !shouldRewriteSelection) {
|
||||
return;
|
||||
}
|
||||
const rewrite = (run?: FollowupRun["run"]) => {
|
||||
if (!run || run.sessionId !== params.previousSessionId) {
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
run.sessionId = params.nextSessionId!;
|
||||
if (params.nextSessionFile?.trim()) {
|
||||
run.sessionFile = params.nextSessionFile;
|
||||
if (shouldRewriteSession && run.sessionId === params.previousSessionId) {
|
||||
run.sessionId = params.nextSessionId!;
|
||||
if (params.nextSessionFile?.trim()) {
|
||||
run.sessionFile = params.nextSessionFile;
|
||||
}
|
||||
}
|
||||
if (shouldRewriteSelection) {
|
||||
if (typeof params.nextProvider === "string") {
|
||||
run.provider = params.nextProvider;
|
||||
}
|
||||
if (typeof params.nextModel === "string") {
|
||||
run.model = params.nextModel;
|
||||
}
|
||||
if (Object.hasOwn(params, "nextAuthProfileId")) {
|
||||
run.authProfileId = params.nextAuthProfileId?.trim() || undefined;
|
||||
}
|
||||
if (Object.hasOwn(params, "nextAuthProfileIdSource")) {
|
||||
run.authProfileIdSource = run.authProfileId ? params.nextAuthProfileIdSource : undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
rewrite(queue.lastRun);
|
||||
|
||||
62
src/auto-reply/reply/queue/state.test.ts
Normal file
62
src/auto-reply/reply/queue/state.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { clearFollowupQueue, getFollowupQueue, refreshQueuedFollowupSession } from "./state.js";
|
||||
import type { FollowupRun } from "./types.js";
|
||||
|
||||
const QUEUE_KEY = "agent:main:dm:test";
|
||||
|
||||
afterEach(() => {
|
||||
clearFollowupQueue(QUEUE_KEY);
|
||||
});
|
||||
|
||||
function makeRun(): FollowupRun["run"] {
|
||||
return {
|
||||
agentId: "main",
|
||||
agentDir: "/tmp/agent",
|
||||
sessionId: "session-1",
|
||||
sessionKey: QUEUE_KEY,
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config: {} as FollowupRun["run"]["config"],
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
authProfileId: "profile-a",
|
||||
authProfileIdSource: "user",
|
||||
timeoutMs: 30_000,
|
||||
blockReplyBreak: "message_end",
|
||||
};
|
||||
}
|
||||
|
||||
describe("refreshQueuedFollowupSession", () => {
|
||||
it("retargets queued runs to the persisted selection", () => {
|
||||
const queue = getFollowupQueue(QUEUE_KEY, { mode: "queue" });
|
||||
const lastRun = makeRun();
|
||||
const queuedRun: FollowupRun = {
|
||||
prompt: "queued message",
|
||||
enqueuedAt: Date.now(),
|
||||
run: makeRun(),
|
||||
};
|
||||
queue.lastRun = lastRun;
|
||||
queue.items.push(queuedRun);
|
||||
|
||||
refreshQueuedFollowupSession({
|
||||
key: QUEUE_KEY,
|
||||
nextProvider: "openai",
|
||||
nextModel: "gpt-4o",
|
||||
nextAuthProfileId: undefined,
|
||||
nextAuthProfileIdSource: undefined,
|
||||
});
|
||||
|
||||
expect(queue.lastRun).toMatchObject({
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
authProfileId: undefined,
|
||||
authProfileIdSource: undefined,
|
||||
});
|
||||
expect(queue.items[0]?.run).toMatchObject({
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
authProfileId: undefined,
|
||||
authProfileIdSource: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -91,26 +91,55 @@ export function refreshQueuedFollowupSession(params: {
|
||||
previousSessionId?: string;
|
||||
nextSessionId?: string;
|
||||
nextSessionFile?: string;
|
||||
nextProvider?: string;
|
||||
nextModel?: string;
|
||||
nextAuthProfileId?: string;
|
||||
nextAuthProfileIdSource?: "auto" | "user";
|
||||
}): void {
|
||||
const cleaned = params.key.trim();
|
||||
if (!cleaned || !params.previousSessionId || !params.nextSessionId) {
|
||||
return;
|
||||
}
|
||||
if (params.previousSessionId === params.nextSessionId) {
|
||||
if (!cleaned) {
|
||||
return;
|
||||
}
|
||||
const queue = getExistingFollowupQueue(cleaned);
|
||||
if (!queue) {
|
||||
return;
|
||||
}
|
||||
const shouldRewriteSession =
|
||||
Boolean(params.previousSessionId) &&
|
||||
Boolean(params.nextSessionId) &&
|
||||
params.previousSessionId !== params.nextSessionId;
|
||||
const shouldRewriteSelection =
|
||||
typeof params.nextProvider === "string" ||
|
||||
typeof params.nextModel === "string" ||
|
||||
Object.hasOwn(params, "nextAuthProfileId") ||
|
||||
Object.hasOwn(params, "nextAuthProfileIdSource");
|
||||
if (!shouldRewriteSession && !shouldRewriteSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rewriteRun = (run?: FollowupRun["run"]) => {
|
||||
if (!run || run.sessionId !== params.previousSessionId) {
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
run.sessionId = params.nextSessionId!;
|
||||
if (params.nextSessionFile?.trim()) {
|
||||
run.sessionFile = params.nextSessionFile;
|
||||
if (shouldRewriteSession && run.sessionId === params.previousSessionId) {
|
||||
run.sessionId = params.nextSessionId!;
|
||||
if (params.nextSessionFile?.trim()) {
|
||||
run.sessionFile = params.nextSessionFile;
|
||||
}
|
||||
}
|
||||
if (shouldRewriteSelection) {
|
||||
if (typeof params.nextProvider === "string") {
|
||||
run.provider = params.nextProvider;
|
||||
}
|
||||
if (typeof params.nextModel === "string") {
|
||||
run.model = params.nextModel;
|
||||
}
|
||||
if (Object.hasOwn(params, "nextAuthProfileId")) {
|
||||
run.authProfileId = params.nextAuthProfileId?.trim() || undefined;
|
||||
}
|
||||
if (Object.hasOwn(params, "nextAuthProfileIdSource")) {
|
||||
run.authProfileIdSource = run.authProfileId ? params.nextAuthProfileIdSource : undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user