mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:00:45 +00:00
Fix active-memory config schema fallback mismatch (#65048)
* fix(active-memory): remove built-in fallback model * fix active-memory config schema fallback fields * fix failover decision external abort typing
This commit is contained in:
24
extensions/active-memory/config.test.ts
Normal file
24
extensions/active-memory/config.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../src/plugins/schema-validator.js";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
|
||||
describe("active-memory manifest config schema", () => {
|
||||
it("accepts modelFallback for CLI and config.patch flows", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.model-fallback",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
modelFallback: "google/gemini-3-flash",
|
||||
modelFallbackPolicy: "resolved-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,15 @@ describe("active-memory plugin", () => {
|
||||
agents: ["main"],
|
||||
logging: true,
|
||||
};
|
||||
api.config = {};
|
||||
api.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "github-copilot/gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
hoisted.sessionStore["agent:main:main"] = {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
@@ -381,7 +389,16 @@ describe("active-memory plugin", () => {
|
||||
});
|
||||
|
||||
it("treats non-default main session keys as direct chats", async () => {
|
||||
api.config = { session: { mainKey: "home" } };
|
||||
api.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "github-copilot/gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { mainKey: "home" },
|
||||
};
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order?", messages: [] },
|
||||
@@ -454,6 +471,8 @@ describe("active-memory plugin", () => {
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
provider: "github-copilot",
|
||||
model: "gpt-5.4-mini",
|
||||
messageChannel: "webchat",
|
||||
messageProvider: "webchat",
|
||||
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
|
||||
});
|
||||
});
|
||||
@@ -730,7 +749,8 @@ describe("active-memory plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("can disable default remote model fallback", async () => {
|
||||
it("skips recall when no model or explicit fallback resolves", async () => {
|
||||
api.config = {};
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
modelFallbackPolicy: "resolved-only",
|
||||
@@ -751,6 +771,53 @@ describe("active-memory plugin", () => {
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses config.modelFallback before the built-in default fallback", async () => {
|
||||
api.config = {};
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
modelFallback: "google/gemini-3-flash",
|
||||
modelFallbackPolicy: "resolved-only",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? custom fallback", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:custom-fallback",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
provider: "google",
|
||||
model: "gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use a built-in fallback model even when default-remote is configured", async () => {
|
||||
api.config = {};
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
modelFallbackPolicy: "default-remote",
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? built-in fallback", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:built-in-fallback",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists a readable debug summary alongside the status line", async () => {
|
||||
const sessionKey = "agent:main:debug";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
@@ -946,10 +1013,43 @@ describe("active-memory plugin", () => {
|
||||
expect(infoLines.some((line: string) => line.includes(" cached "))).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores late subagent payloads once the active-memory timeout signal has fired", async () => {
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
timeoutMs: 250,
|
||||
logging: true,
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedPiAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 25));
|
||||
return {
|
||||
payloads: [{ text: "late timeout payload that should never become memory context" }],
|
||||
meta: { aborted: true },
|
||||
};
|
||||
});
|
||||
|
||||
const result = await hooks.before_prompt_build(
|
||||
{ prompt: "what wings should i order? late payload timeout", messages: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:late-timeout-payload",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
const infoLines = vi
|
||||
.mocked(api.logger.info)
|
||||
.mock.calls.map((call: unknown[]) => String(call[0]));
|
||||
expect(infoLines.some((line: string) => line.includes("status=timeout"))).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a canonical agent session key when only sessionId is available", async () => {
|
||||
hoisted.sessionStore["agent:main:telegram:direct:12345"] = {
|
||||
sessionId: "session-a",
|
||||
updatedAt: 25,
|
||||
channel: "telegram",
|
||||
};
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
@@ -965,6 +1065,10 @@ describe("active-memory plugin", () => {
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.sessionKey).toMatch(
|
||||
/^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/,
|
||||
);
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
messageChannel: "telegram",
|
||||
messageProvider: "webchat",
|
||||
});
|
||||
expect(hoisted.sessionStore["agent:main:telegram:direct:12345"]?.pluginDebugEntries).toEqual([
|
||||
{
|
||||
pluginId: "active-memory",
|
||||
|
||||
@@ -25,7 +25,6 @@ const DEFAULT_RECENT_USER_CHARS = 220;
|
||||
const DEFAULT_RECENT_ASSISTANT_CHARS = 180;
|
||||
const DEFAULT_CACHE_TTL_MS = 15_000;
|
||||
const DEFAULT_MAX_CACHE_ENTRIES = 1000;
|
||||
const DEFAULT_MODEL_REF = "github-copilot/gpt-5.4-mini";
|
||||
const DEFAULT_QUERY_MODE = "recent" as const;
|
||||
const DEFAULT_TRANSCRIPT_DIR = "active-memory";
|
||||
const TOGGLE_STATE_FILE = "session-toggles.json";
|
||||
@@ -58,6 +57,7 @@ type ActiveRecallPluginConfig = {
|
||||
enabled?: boolean;
|
||||
agents?: string[];
|
||||
model?: string;
|
||||
modelFallback?: string;
|
||||
modelFallbackPolicy?: "default-remote" | "resolved-only";
|
||||
allowedChatTypes?: Array<"direct" | "group" | "channel">;
|
||||
thinking?: ActiveMemoryThinkingLevel;
|
||||
@@ -87,6 +87,7 @@ type ResolvedActiveRecallPluginConfig = {
|
||||
enabled: boolean;
|
||||
agents: string[];
|
||||
model?: string;
|
||||
modelFallback?: string;
|
||||
modelFallbackPolicy: "default-remote" | "resolved-only";
|
||||
allowedChatTypes: Array<"direct" | "group" | "channel">;
|
||||
thinking: ActiveMemoryThinkingLevel;
|
||||
@@ -314,6 +315,65 @@ function resolveCanonicalSessionKeyFromSessionId(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveRecallRunChannelContext(params: {
|
||||
api: OpenClawPluginApi;
|
||||
agentId: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
}): {
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
} {
|
||||
const explicitChannel = normalizeOptionalString(params.channelId);
|
||||
const explicitProvider = normalizeOptionalString(params.messageProvider);
|
||||
const resolvedSessionKey =
|
||||
normalizeOptionalString(params.sessionKey) ??
|
||||
resolveCanonicalSessionKeyFromSessionId({
|
||||
api: params.api,
|
||||
agentId: params.agentId,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
if (!resolvedSessionKey) {
|
||||
return {
|
||||
messageChannel: explicitChannel ?? explicitProvider,
|
||||
messageProvider: explicitProvider ?? explicitChannel,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const storePath = params.api.runtime.agent.session.resolveStorePath(
|
||||
params.api.config.session?.store,
|
||||
{
|
||||
agentId: params.agentId,
|
||||
},
|
||||
);
|
||||
const store = params.api.runtime.agent.session.loadSessionStore(storePath);
|
||||
const sessionEntry = resolveSessionStoreEntry({
|
||||
store,
|
||||
sessionKey: resolvedSessionKey,
|
||||
}).existing;
|
||||
const entryChannel =
|
||||
normalizeOptionalString(sessionEntry?.lastChannel) ??
|
||||
normalizeOptionalString(sessionEntry?.channel) ??
|
||||
normalizeOptionalString(sessionEntry?.origin?.provider);
|
||||
return {
|
||||
messageChannel: explicitChannel ?? entryChannel ?? explicitProvider,
|
||||
messageProvider: explicitProvider ?? explicitChannel ?? entryChannel,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
messageChannel: explicitChannel ?? explicitProvider,
|
||||
messageProvider: explicitProvider ?? explicitChannel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToggleStatePath(api: OpenClawPluginApi): string {
|
||||
return path.join(
|
||||
api.runtime.state.resolveStateDir(),
|
||||
@@ -498,6 +558,10 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi
|
||||
? raw.agents.map((agentId) => agentId.trim()).filter(Boolean)
|
||||
: [],
|
||||
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : undefined,
|
||||
modelFallback:
|
||||
typeof raw.modelFallback === "string" && raw.modelFallback.trim()
|
||||
? raw.modelFallback.trim()
|
||||
: undefined,
|
||||
modelFallbackPolicy:
|
||||
raw.modelFallbackPolicy === "resolved-only" ? "resolved-only" : "default-remote",
|
||||
allowedChatTypes: allowedChatTypes.length > 0 ? allowedChatTypes : ["direct"],
|
||||
@@ -1136,6 +1200,15 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] {
|
||||
return turns;
|
||||
}
|
||||
|
||||
function parseModelCandidate(modelRef: string | undefined) {
|
||||
if (!modelRef) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
parseModelRef(modelRef, DEFAULT_PROVIDER) ?? { provider: DEFAULT_PROVIDER, model: modelRef }
|
||||
);
|
||||
}
|
||||
|
||||
function getModelRef(
|
||||
api: OpenClawPluginApi,
|
||||
agentId: string,
|
||||
@@ -1144,31 +1217,35 @@ function getModelRef(
|
||||
modelProviderId?: string;
|
||||
modelId?: string;
|
||||
},
|
||||
) {
|
||||
): {
|
||||
modelRef?: {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
source: "plugin-model" | "session-model" | "agent-primary" | "config-fallback" | "none";
|
||||
} {
|
||||
const currentRunModel =
|
||||
ctx?.modelProviderId && ctx?.modelId ? `${ctx.modelProviderId}/${ctx.modelId}` : undefined;
|
||||
const agentPrimaryModel = resolveAgentEffectiveModelPrimary(api.config, agentId);
|
||||
const configured =
|
||||
config.model ||
|
||||
currentRunModel ||
|
||||
agentPrimaryModel ||
|
||||
(config.modelFallbackPolicy === "default-remote" ? DEFAULT_MODEL_REF : undefined);
|
||||
if (!configured) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseModelRef(configured, DEFAULT_PROVIDER);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
const parsedAgentPrimary = agentPrimaryModel
|
||||
? parseModelRef(agentPrimaryModel, DEFAULT_PROVIDER)
|
||||
: undefined;
|
||||
return (
|
||||
parsedAgentPrimary ?? {
|
||||
provider: DEFAULT_PROVIDER,
|
||||
model: configured,
|
||||
const candidates: Array<{
|
||||
source: "plugin-model" | "session-model" | "agent-primary" | "config-fallback";
|
||||
value?: string;
|
||||
}> = [
|
||||
{ source: "plugin-model", value: config.model },
|
||||
{ source: "session-model", value: currentRunModel },
|
||||
{ source: "agent-primary", value: agentPrimaryModel },
|
||||
{ source: "config-fallback", value: config.modelFallback },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseModelCandidate(candidate.value);
|
||||
if (parsed) {
|
||||
return {
|
||||
modelRef: parsed,
|
||||
source: candidate.source,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
return { source: "none" };
|
||||
}
|
||||
|
||||
async function runRecallSubagent(params: {
|
||||
@@ -1177,6 +1254,8 @@ async function runRecallSubagent(params: {
|
||||
agentId: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
query: string;
|
||||
currentModelProviderId?: string;
|
||||
currentModelId?: string;
|
||||
@@ -1184,7 +1263,7 @@ async function runRecallSubagent(params: {
|
||||
}): Promise<{ rawReply: string; transcriptPath?: string }> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId);
|
||||
const agentDir = resolveAgentDir(params.api.config, params.agentId);
|
||||
const modelRef = getModelRef(params.api, params.agentId, params.config, {
|
||||
const { modelRef } = getModelRef(params.api, params.agentId, params.config, {
|
||||
modelProviderId: params.currentModelProviderId,
|
||||
modelId: params.currentModelId,
|
||||
});
|
||||
@@ -1228,12 +1307,22 @@ async function runRecallSubagent(params: {
|
||||
config: params.config,
|
||||
query: params.query,
|
||||
});
|
||||
const { messageChannel, messageProvider } = resolveRecallRunChannelContext({
|
||||
api: params.api,
|
||||
agentId: params.agentId,
|
||||
sessionKey: parentSessionKey,
|
||||
sessionId: params.sessionId,
|
||||
messageProvider: params.messageProvider,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await params.api.runtime.agent.runEmbeddedPiAgent({
|
||||
sessionId: subagentSessionId,
|
||||
sessionKey: subagentSessionKey,
|
||||
agentId: params.agentId,
|
||||
messageChannel,
|
||||
messageProvider,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
@@ -1253,6 +1342,18 @@ async function runRecallSubagent(params: {
|
||||
silentExpected: true,
|
||||
abortSignal: params.abortSignal,
|
||||
});
|
||||
if (params.abortSignal?.aborted) {
|
||||
const reason = params.abortSignal.reason;
|
||||
if (reason instanceof Error) {
|
||||
throw reason;
|
||||
}
|
||||
const abortErr =
|
||||
reason !== undefined
|
||||
? new Error("Operation aborted", { cause: reason })
|
||||
: new Error("Operation aborted");
|
||||
abortErr.name = "AbortError";
|
||||
throw abortErr;
|
||||
}
|
||||
const rawReply = (result.payloads ?? [])
|
||||
.map((payload) => payload.text?.trim() ?? "")
|
||||
.filter(Boolean)
|
||||
@@ -1275,6 +1376,8 @@ async function maybeResolveActiveRecall(params: {
|
||||
agentId: string;
|
||||
sessionKey?: string;
|
||||
sessionId?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
query: string;
|
||||
currentModelProviderId?: string;
|
||||
currentModelId?: string;
|
||||
@@ -1539,6 +1642,8 @@ export default definePluginEntry({
|
||||
agentId: effectiveAgentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
sessionId: ctx.sessionId,
|
||||
messageProvider: ctx.messageProvider,
|
||||
channelId: ctx.channelId,
|
||||
query,
|
||||
currentModelProviderId: ctx.modelProviderId,
|
||||
currentModelId: ctx.modelId,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"model": { "type": "string" },
|
||||
"modelFallback": { "type": "string" },
|
||||
"modelFallbackPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["default-remote", "resolved-only"]
|
||||
@@ -69,9 +70,13 @@
|
||||
"label": "Memory Model",
|
||||
"help": "Provider/model used for the blocking memory sub-agent."
|
||||
},
|
||||
"modelFallback": {
|
||||
"label": "Fallback Memory Model",
|
||||
"help": "Optional provider/model to use if no explicit plugin model, session model, or agent primary model resolves."
|
||||
},
|
||||
"modelFallbackPolicy": {
|
||||
"label": "Model Fallback Policy",
|
||||
"help": "Choose whether Active Memory falls back to the built-in remote default model when no explicit or inherited model is available."
|
||||
"help": "Deprecated compatibility field. Active Memory no longer uses a built-in fallback model; set modelFallback explicitly if you want a fallback."
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"label": "Allowed Chat Types",
|
||||
|
||||
Reference in New Issue
Block a user