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:
Tak Hoffman
2026-04-11 20:19:42 -05:00
committed by GitHub
parent 51731d906f
commit 5d0b5388fa
10 changed files with 333 additions and 28 deletions

View 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);
});
});

View File

@@ -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",

View File

@@ -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,

View File

@@ -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",