mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:41:37 +00:00
feat(auto-reply): add model fallback lifecycle visibility in status, verbose logs, and WebUI (#20704)
This commit is contained in:
@@ -40,12 +40,23 @@ import type { FollowupRun } from "./queue.js";
|
||||
import { createBlockReplyDeliveryHandler } from "./reply-delivery.js";
|
||||
import type { TypingSignaler } from "./typing-mode.js";
|
||||
|
||||
export type RuntimeFallbackAttempt = {
|
||||
provider: string;
|
||||
model: string;
|
||||
error: string;
|
||||
reason?: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
export type AgentRunLoopResult =
|
||||
| {
|
||||
kind: "success";
|
||||
runId: string;
|
||||
runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
fallbackProvider?: string;
|
||||
fallbackModel?: string;
|
||||
fallbackAttempts: RuntimeFallbackAttempt[];
|
||||
didLogHeartbeatStrip: boolean;
|
||||
autoCompactionCompleted: boolean;
|
||||
/** Payload keys sent directly (not via pipeline) during tool flush. */
|
||||
@@ -106,6 +117,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = params.followupRun.run.provider;
|
||||
let fallbackModel = params.followupRun.run.model;
|
||||
let fallbackAttempts: RuntimeFallbackAttempt[] = [];
|
||||
let didResetAfterCompactionFailure = false;
|
||||
let didRetryTransientHttpError = false;
|
||||
|
||||
@@ -397,6 +409,16 @@ export async function runAgentTurnWithFallback(params: {
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
fallbackAttempts = Array.isArray(fallbackResult.attempts)
|
||||
? fallbackResult.attempts.map((attempt) => ({
|
||||
provider: String(attempt.provider ?? ""),
|
||||
model: String(attempt.model ?? ""),
|
||||
error: String(attempt.error ?? ""),
|
||||
reason: attempt.reason ? String(attempt.reason) : undefined,
|
||||
status: typeof attempt.status === "number" ? attempt.status : undefined,
|
||||
code: attempt.code ? String(attempt.code) : undefined,
|
||||
}))
|
||||
: [];
|
||||
|
||||
// Some embedded runs surface context overflow as an error payload instead of throwing.
|
||||
// Treat those as a session-level failure and auto-recover by starting a fresh session.
|
||||
@@ -543,9 +565,11 @@ export async function runAgentTurnWithFallback(params: {
|
||||
|
||||
return {
|
||||
kind: "success",
|
||||
runId,
|
||||
runResult,
|
||||
fallbackProvider,
|
||||
fallbackModel,
|
||||
fallbackAttempts,
|
||||
didLogHeartbeatStrip,
|
||||
autoCompactionCompleted,
|
||||
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock("../../agents/model-fallback.js", () => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
attempts: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -508,6 +509,30 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
expect(onToolResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries transient HTTP failures once with timer-driven backoff", async () => {
|
||||
vi.useFakeTimers();
|
||||
let calls = 0;
|
||||
state.runEmbeddedPiAgentMock.mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("502 Bad Gateway");
|
||||
}
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
typingMode: "message",
|
||||
});
|
||||
const runPromise = run();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_499);
|
||||
expect(calls).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await runPromise;
|
||||
expect(calls).toBe(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("announces auto-compaction in verbose mode and tracks count", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
@@ -538,12 +563,482 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("announces model fallback in verbose mode", async () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const res = await run();
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
const payloads = res as { text?: string }[];
|
||||
expect(payloads[0]?.text).toContain("Model Fallback:");
|
||||
expect(payloads[0]?.text).toContain("deepinfra/moonshotai/Kimi-K2.5");
|
||||
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
|
||||
});
|
||||
|
||||
it("does not announce model fallback when verbose is off", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ payloads: [{ text: "final" }], meta: {} });
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
vi.spyOn(modelFallback, "runWithModelFallback").mockImplementationOnce(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "off",
|
||||
});
|
||||
const phases: string[] = [];
|
||||
const off = onAgentEvent((evt) => {
|
||||
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
if (evt.stream === "lifecycle" && phase) {
|
||||
phases.push(phase);
|
||||
}
|
||||
});
|
||||
const res = await run();
|
||||
off();
|
||||
const payload = Array.isArray(res) ? (res[0] as { text?: string }) : (res as { text?: string });
|
||||
expect(payload.text).not.toContain("Model Fallback:");
|
||||
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("announces model fallback only once per active fallback state", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const fallbackEvents: Array<Record<string, unknown>> = [];
|
||||
const off = onAgentEvent((evt) => {
|
||||
if (evt.stream === "lifecycle" && evt.data?.phase === "fallback") {
|
||||
fallbackEvents.push(evt.data);
|
||||
}
|
||||
});
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
off();
|
||||
|
||||
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
|
||||
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
|
||||
expect(firstText).toContain("Model Fallback:");
|
||||
expect(secondText).not.toContain("Model Fallback:");
|
||||
expect(fallbackEvents).toHaveLength(1);
|
||||
} finally {
|
||||
fallbackSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-announces model fallback after returning to selected model", async () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
let callCount = 0;
|
||||
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => {
|
||||
callCount += 1;
|
||||
if (callCount === 2) {
|
||||
return {
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
attempts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
const third = await run();
|
||||
|
||||
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
|
||||
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
|
||||
const thirdText = Array.isArray(third) ? third[0]?.text : third?.text;
|
||||
expect(firstText).toContain("Model Fallback:");
|
||||
expect(secondText).not.toContain("Model Fallback:");
|
||||
expect(thirdText).toContain("Model Fallback:");
|
||||
} finally {
|
||||
fallbackSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("announces fallback-cleared once when runtime returns to selected model", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
let callCount = 0;
|
||||
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
attempts: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const phases: string[] = [];
|
||||
const off = onAgentEvent((evt) => {
|
||||
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
if (evt.stream === "lifecycle" && phase) {
|
||||
phases.push(phase);
|
||||
}
|
||||
});
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
const third = await run();
|
||||
off();
|
||||
|
||||
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
|
||||
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
|
||||
const thirdText = Array.isArray(third) ? third[0]?.text : third?.text;
|
||||
expect(firstText).toContain("Model Fallback:");
|
||||
expect(secondText).toContain("Model Fallback cleared:");
|
||||
expect(thirdText).not.toContain("Model Fallback cleared:");
|
||||
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
|
||||
expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1);
|
||||
} finally {
|
||||
fallbackSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits fallback lifecycle events while verbose is off", async () => {
|
||||
const { onAgentEvent } = await import("../../infra/agent-events.js");
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
let callCount = 0;
|
||||
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => {
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
error: "Provider fireworks is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
attempts: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
try {
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "off",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const phases: string[] = [];
|
||||
const off = onAgentEvent((evt) => {
|
||||
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
if (evt.stream === "lifecycle" && phase) {
|
||||
phases.push(phase);
|
||||
}
|
||||
});
|
||||
const first = await run();
|
||||
const second = await run();
|
||||
off();
|
||||
|
||||
const firstText = Array.isArray(first) ? first[0]?.text : first?.text;
|
||||
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
|
||||
expect(firstText).not.toContain("Model Fallback:");
|
||||
expect(secondText).not.toContain("Model Fallback cleared:");
|
||||
expect(phases.filter((phase) => phase === "fallback")).toHaveLength(1);
|
||||
expect(phases.filter((phase) => phase === "fallback_cleared")).toHaveLength(1);
|
||||
} finally {
|
||||
fallbackSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("backfills fallback reason when fallback is already active", async () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
fallbackNoticeSelectedModel: "anthropic/claude",
|
||||
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
modelProvider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
error: "Provider anthropic is in cooldown (all profiles unavailable)",
|
||||
reason: "rate_limit",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const res = await run();
|
||||
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(firstText).not.toContain("Model Fallback:");
|
||||
expect(sessionEntry.fallbackNoticeReason).toBe("rate limit");
|
||||
} finally {
|
||||
fallbackSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("refreshes fallback reason summary while fallback stays active", async () => {
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
fallbackNoticeSelectedModel: "anthropic/claude",
|
||||
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
modelProvider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "final" }],
|
||||
meta: {},
|
||||
});
|
||||
const modelFallback = await import("../../agents/model-fallback.js");
|
||||
const fallbackSpy = vi
|
||||
.spyOn(modelFallback, "runWithModelFallback")
|
||||
.mockImplementation(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("deepinfra", "moonshotai/Kimi-K2.5"),
|
||||
provider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
attempts: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
error: "Provider anthropic is in cooldown (all profiles unavailable)",
|
||||
reason: "timeout",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
});
|
||||
const res = await run();
|
||||
const firstText = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(firstText).not.toContain("Model Fallback:");
|
||||
expect(sessionEntry.fallbackNoticeReason).toBe("timeout");
|
||||
} finally {
|
||||
fallbackSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries after compaction failure by resetting the session", async () => {
|
||||
await withTempStateDir(async (stateDir) => {
|
||||
const sessionId = "session";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||
const sessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
sessionFile: transcriptPath,
|
||||
fallbackNoticeSelectedModel: "fireworks/minimax-m2p5",
|
||||
fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
fallbackNoticeReason: "rate limit",
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
@@ -575,9 +1070,15 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
}
|
||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
expect(sessionStore.main.fallbackNoticeSelectedModel).toBeUndefined();
|
||||
expect(sessionStore.main.fallbackNoticeActiveModel).toBeUndefined();
|
||||
expect(sessionStore.main.fallbackNoticeReason).toBeUndefined();
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||
expect(persisted.main.fallbackNoticeSelectedModel).toBeUndefined();
|
||||
expect(persisted.main.fallbackNoticeActiveModel).toBeUndefined();
|
||||
expect(persisted.main.fallbackNoticeReason).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,10 +15,16 @@ import {
|
||||
updateSessionStoreEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import { emitAgentEvent } from "../../infra/agent-events.js";
|
||||
import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
|
||||
import {
|
||||
buildFallbackClearedNotice,
|
||||
buildFallbackNotice,
|
||||
resolveFallbackTransition,
|
||||
} from "../fallback-state.js";
|
||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
@@ -290,6 +296,9 @@ export async function runReplyAgent(params: {
|
||||
updatedAt: Date.now(),
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
fallbackNoticeSelectedModel: undefined,
|
||||
fallbackNoticeActiveModel: undefined,
|
||||
fallbackNoticeReason: undefined,
|
||||
};
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const nextSessionFile = resolveSessionTranscriptPath(
|
||||
@@ -373,7 +382,14 @@ export async function runReplyAgent(params: {
|
||||
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
|
||||
}
|
||||
|
||||
const { runResult, fallbackProvider, fallbackModel, directlySentBlockKeys } = runOutcome;
|
||||
const {
|
||||
runId,
|
||||
runResult,
|
||||
fallbackProvider,
|
||||
fallbackModel,
|
||||
fallbackAttempts,
|
||||
directlySentBlockKeys,
|
||||
} = runOutcome;
|
||||
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
|
||||
|
||||
if (
|
||||
@@ -414,6 +430,42 @@ export async function runReplyAgent(params: {
|
||||
const modelUsed = runResult.meta?.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const providerUsed =
|
||||
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
|
||||
const verboseEnabled = resolvedVerboseLevel !== "off";
|
||||
const selectedProvider = followupRun.run.provider;
|
||||
const selectedModel = followupRun.run.model;
|
||||
const fallbackStateEntry =
|
||||
activeSessionEntry ?? (sessionKey ? activeSessionStore?.[sessionKey] : undefined);
|
||||
const fallbackTransition = resolveFallbackTransition({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
activeProvider: providerUsed,
|
||||
activeModel: modelUsed,
|
||||
attempts: fallbackAttempts,
|
||||
state: fallbackStateEntry,
|
||||
});
|
||||
if (fallbackTransition.stateChanged) {
|
||||
if (fallbackStateEntry) {
|
||||
fallbackStateEntry.fallbackNoticeSelectedModel = fallbackTransition.nextState.selectedModel;
|
||||
fallbackStateEntry.fallbackNoticeActiveModel = fallbackTransition.nextState.activeModel;
|
||||
fallbackStateEntry.fallbackNoticeReason = fallbackTransition.nextState.reason;
|
||||
fallbackStateEntry.updatedAt = Date.now();
|
||||
activeSessionEntry = fallbackStateEntry;
|
||||
}
|
||||
if (sessionKey && fallbackStateEntry && activeSessionStore) {
|
||||
activeSessionStore[sessionKey] = fallbackStateEntry;
|
||||
}
|
||||
if (sessionKey && storePath) {
|
||||
await updateSessionStoreEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
update: async () => ({
|
||||
fallbackNoticeSelectedModel: fallbackTransition.nextState.selectedModel,
|
||||
fallbackNoticeActiveModel: fallbackTransition.nextState.activeModel,
|
||||
fallbackNoticeReason: fallbackTransition.nextState.reason,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const cliSessionId = isCliProvider(providerUsed, cfg)
|
||||
? runResult.meta?.agentMeta?.sessionId?.trim()
|
||||
: undefined;
|
||||
@@ -546,9 +598,68 @@ export async function runReplyAgent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||
// If verbose is enabled, prepend operational run notices.
|
||||
let finalPayloads = guardedReplyPayloads;
|
||||
const verboseEnabled = resolvedVerboseLevel !== "off";
|
||||
const verboseNotices: ReplyPayload[] = [];
|
||||
|
||||
if (verboseEnabled && activeIsNewSession) {
|
||||
verboseNotices.push({ text: `🧭 New session: ${followupRun.run.sessionId}` });
|
||||
}
|
||||
|
||||
if (fallbackTransition.fallbackTransitioned) {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
activeProvider: providerUsed,
|
||||
activeModel: modelUsed,
|
||||
reasonSummary: fallbackTransition.reasonSummary,
|
||||
attemptSummaries: fallbackTransition.attemptSummaries,
|
||||
attempts: fallbackAttempts,
|
||||
},
|
||||
});
|
||||
if (verboseEnabled) {
|
||||
const fallbackNotice = buildFallbackNotice({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
activeProvider: providerUsed,
|
||||
activeModel: modelUsed,
|
||||
attempts: fallbackAttempts,
|
||||
});
|
||||
if (fallbackNotice) {
|
||||
verboseNotices.push({ text: fallbackNotice });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fallbackTransition.fallbackCleared) {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "fallback_cleared",
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
activeProvider: providerUsed,
|
||||
activeModel: modelUsed,
|
||||
previousActiveModel: fallbackTransition.previousState.activeModel,
|
||||
},
|
||||
});
|
||||
if (verboseEnabled) {
|
||||
verboseNotices.push({
|
||||
text: buildFallbackClearedNotice({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
previousActiveModel: fallbackTransition.previousState.activeModel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementRunCompactionCount({
|
||||
sessionEntry: activeSessionEntry,
|
||||
@@ -578,11 +689,11 @@ export async function runReplyAgent(params: {
|
||||
|
||||
if (verboseEnabled) {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
finalPayloads = [{ text: `🧹 Auto-compaction complete${suffix}.` }, ...finalPayloads];
|
||||
verboseNotices.push({ text: `🧹 Auto-compaction complete${suffix}.` });
|
||||
}
|
||||
}
|
||||
if (verboseEnabled && activeIsNewSession) {
|
||||
finalPayloads = [{ text: `🧭 New session: ${followupRun.run.sessionId}` }, ...finalPayloads];
|
||||
if (verboseNotices.length > 0) {
|
||||
finalPayloads = [...verboseNotices, ...finalPayloads];
|
||||
}
|
||||
if (responseUsageLine) {
|
||||
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../infra/provider-usage.js";
|
||||
import type { MediaUnderstandingDecision } from "../../media-understanding/types.js";
|
||||
import { normalizeGroupActivation } from "../group-activation.js";
|
||||
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
|
||||
import { buildStatusMessage } from "../status.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
@@ -136,6 +137,25 @@ export async function buildStatusReply(params: {
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
|
||||
: undefined;
|
||||
const modelRefs = resolveSelectedAndActiveModel({
|
||||
selectedProvider: provider,
|
||||
selectedModel: model,
|
||||
sessionEntry,
|
||||
});
|
||||
const selectedModelAuth = resolveModelAuthLabel({
|
||||
provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
agentDir: statusAgentDir,
|
||||
});
|
||||
const activeModelAuth = modelRefs.activeDiffers
|
||||
? resolveModelAuthLabel({
|
||||
provider: modelRefs.active.provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
agentDir: statusAgentDir,
|
||||
})
|
||||
: selectedModelAuth;
|
||||
const agentDefaults = cfg.agents?.defaults ?? {};
|
||||
const statusText = buildStatusMessage({
|
||||
config: cfg,
|
||||
@@ -160,12 +180,8 @@ export async function buildStatusReply(params: {
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedReasoning: resolvedReasoningLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
modelAuth: resolveModelAuthLabel({
|
||||
provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
agentDir: statusAgentDir,
|
||||
}),
|
||||
modelAuth: selectedModelAuth,
|
||||
activeModelAuth,
|
||||
usageLine: usageLine ?? undefined,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
|
||||
@@ -106,6 +106,7 @@ export async function handleDirectiveOnly(
|
||||
allowedModelCatalog,
|
||||
resetModelOverride,
|
||||
surface: params.surface,
|
||||
sessionEntry,
|
||||
});
|
||||
if (modelInfo) {
|
||||
return modelInfo;
|
||||
|
||||
@@ -63,6 +63,32 @@ describe("/model chat UX", () => {
|
||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||
});
|
||||
|
||||
it("shows active runtime model when different from selected model", async () => {
|
||||
const directives = parseInlineDirectives("/model");
|
||||
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
|
||||
|
||||
const reply = await maybeHandleModelDirectiveInfo({
|
||||
directives,
|
||||
cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
activeAgentId: "main",
|
||||
provider: "fireworks",
|
||||
model: "fireworks/minimax-m2p5",
|
||||
defaultProvider: "fireworks",
|
||||
defaultModel: "fireworks/minimax-m2p5",
|
||||
aliasIndex: baseAliasIndex(),
|
||||
allowedModelCatalog: [],
|
||||
resetModelOverride: false,
|
||||
sessionEntry: {
|
||||
modelProvider: "deepinfra",
|
||||
model: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
expect(reply?.text).toContain("Current: fireworks/minimax-m2p5 (selected)");
|
||||
expect(reply?.text).toContain("Active: deepinfra/moonshotai/Kimi-K2.5 (runtime)");
|
||||
});
|
||||
|
||||
it("auto-applies closest match for typos", () => {
|
||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { resolveModelsCommandReply } from "./commands-models.js";
|
||||
import {
|
||||
@@ -198,6 +200,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
|
||||
resetModelOverride: boolean;
|
||||
surface?: string;
|
||||
sessionEntry?: Pick<SessionEntry, "modelProvider" | "model">;
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
if (!params.directives.hasModelDirective) {
|
||||
return undefined;
|
||||
@@ -233,31 +236,45 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
}
|
||||
|
||||
if (wantsSummary) {
|
||||
const current = `${params.provider}/${params.model}`;
|
||||
const modelRefs = resolveSelectedAndActiveModel({
|
||||
selectedProvider: params.provider,
|
||||
selectedModel: params.model,
|
||||
sessionEntry: params.sessionEntry,
|
||||
});
|
||||
const current = modelRefs.selected.label;
|
||||
const isTelegram = params.surface === "telegram";
|
||||
const activeRuntimeLine = modelRefs.activeDiffers
|
||||
? `Active: ${modelRefs.active.label} (runtime)`
|
||||
: null;
|
||||
|
||||
if (isTelegram) {
|
||||
const buttons = buildBrowseProvidersButton();
|
||||
return {
|
||||
text: [
|
||||
`Current: ${current}`,
|
||||
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
|
||||
activeRuntimeLine,
|
||||
"",
|
||||
"Tap below to browse models, or use:",
|
||||
"/model <provider/model> to switch",
|
||||
"/model status for details",
|
||||
].join("\n"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
channelData: { telegram: { buttons } },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: [
|
||||
`Current: ${current}`,
|
||||
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
|
||||
activeRuntimeLine,
|
||||
"",
|
||||
"Switch: /model <provider/model>",
|
||||
"Browse: /models (providers) or /models <provider> (models)",
|
||||
"More: /model status",
|
||||
].join("\n"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,14 +301,20 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
authByProvider.set(provider, formatAuthLabel(auth));
|
||||
}
|
||||
|
||||
const current = `${params.provider}/${params.model}`;
|
||||
const modelRefs = resolveSelectedAndActiveModel({
|
||||
selectedProvider: params.provider,
|
||||
selectedModel: params.model,
|
||||
sessionEntry: params.sessionEntry,
|
||||
});
|
||||
const current = modelRefs.selected.label;
|
||||
const defaultLabel = `${params.defaultProvider}/${params.defaultModel}`;
|
||||
const lines = [
|
||||
`Current: ${current}`,
|
||||
`Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`,
|
||||
modelRefs.activeDiffers ? `Active: ${modelRefs.active.label} (runtime)` : null,
|
||||
`Default: ${defaultLabel}`,
|
||||
`Agent: ${params.activeAgentId}`,
|
||||
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(params.agentDir))}`,
|
||||
];
|
||||
].filter((line): line is string => Boolean(line));
|
||||
if (params.resetModelOverride) {
|
||||
lines.push(`(previous selection reset to default)`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user