Memory/QMD: reuse default model cache and skip ENOENT warnings (#12114)

* Memory/QMD: symlink default model cache into custom XDG_CACHE_HOME

QmdMemoryManager overrides XDG_CACHE_HOME to isolate the qmd index
per-agent, but this also moves where qmd looks for its ML models
(~2.1GB). Since models are installed at the default location
(~/.cache/qmd/models/), every qmd invocation would attempt to
re-download them from HuggingFace and time out.

Fix: on initialization, symlink ~/.cache/qmd/models/ into the custom
XDG_CACHE_HOME path so the index stays isolated per-agent while the
shared models are reused. The symlink is only created when the default
models directory exists and the target path does not already exist.

Includes tests for the three key scenarios: symlink creation, existing
directory preservation, and graceful skip when no default models exist.

* Memory/QMD: skip model symlink warning on ENOENT

* test: stabilize warning-filter visibility assertion (#12114) (thanks @tyler6204)

* fix: add changelog entry for QMD cache reuse (#12114) (thanks @tyler6204)

* fix: handle plain context-overflow strings in compaction detection (#12114) (thanks @tyler6204)
This commit is contained in:
Tyler Yust 2026-02-08 23:43:08 -08:00 committed by GitHub
parent c984e6d8df
commit e4651d6afa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 190 additions and 30 deletions

View File

@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705)
- Memory/QMD: log explicit warnings when `memory.qmd.scope` blocks a search request. (#10191)
- Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.

View File

@ -1,14 +1,5 @@
import { describe, expect, it } from "vitest";
import { isCompactionFailureError } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
import { isCompactionFailureError } from "./pi-embedded-helpers/errors.js";
describe("isCompactionFailureError", () => {
it("matches compaction overflow failures", () => {
const samples = [

View File

@ -25,6 +25,7 @@ export function isContextOverflowError(errorMessage?: string): boolean {
lower.includes("exceeds model context window") ||
(hasRequestSizeExceeds && hasContextWindow) ||
lower.includes("context overflow:") ||
lower.includes("context overflow") ||
(lower.includes("413") && lower.includes("too large"))
);
}

View File

@ -56,28 +56,42 @@ describe("warning filter", () => {
});
it("installs once and suppresses known warnings at emit time", async () => {
const baseEmitSpy = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
const seenWarnings: Array<{ code?: string; name: string; message: string }> = [];
const onWarning = (warning: Error & { code?: string }) => {
seenWarnings.push({
code: warning.code,
name: warning.name,
message: warning.message,
});
};
installProcessWarningFilter();
installProcessWarningFilter();
installProcessWarningFilter();
const emitWarning = (...args: unknown[]) =>
(process.emitWarning as unknown as (...warningArgs: unknown[]) => void)(...args);
process.on("warning", onWarning);
try {
installProcessWarningFilter();
installProcessWarningFilter();
installProcessWarningFilter();
const emitWarning = (...args: unknown[]) =>
(process.emitWarning as unknown as (...warningArgs: unknown[]) => void)(...args);
emitWarning(
"The `util._extend` API is deprecated. Please use Object.assign() instead.",
"DeprecationWarning",
"DEP0060",
);
emitWarning("The `util._extend` API is deprecated. Please use Object.assign() instead.", {
type: "DeprecationWarning",
code: "DEP0060",
});
await new Promise((resolve) => setImmediate(resolve));
expect(baseEmitSpy).not.toHaveBeenCalled();
emitWarning(
"The `util._extend` API is deprecated. Please use Object.assign() instead.",
"DeprecationWarning",
"DEP0060",
);
emitWarning("The `util._extend` API is deprecated. Please use Object.assign() instead.", {
type: "DeprecationWarning",
code: "DEP0060",
});
await new Promise((resolve) => setImmediate(resolve));
expect(seenWarnings.find((warning) => warning.code === "DEP0060")).toBeUndefined();
emitWarning("Visible warning", { type: "Warning", code: "OPENCLAW_TEST_WARNING" });
await new Promise((resolve) => setImmediate(resolve));
expect(baseEmitSpy).toHaveBeenCalledTimes(1);
emitWarning("Visible warning", { type: "Warning", code: "OPENCLAW_TEST_WARNING" });
await new Promise((resolve) => setImmediate(resolve));
expect(
seenWarnings.find((warning) => warning.code === "OPENCLAW_TEST_WARNING"),
).toBeDefined();
} finally {
process.off("warning", onWarning);
}
});
});

View File

@ -604,6 +604,89 @@ describe("QmdMemoryManager", () => {
).rejects.toThrow("qmd index busy while reading results");
await manager.close();
});
describe("model cache symlink", () => {
let defaultModelsDir: string;
let customModelsDir: string;
let savedXdgCacheHome: string | undefined;
beforeEach(async () => {
// Redirect XDG_CACHE_HOME so symlinkSharedModels finds our fake models
// directory instead of the real ~/.cache.
savedXdgCacheHome = process.env.XDG_CACHE_HOME;
const fakeCacheHome = path.join(tmpRoot, "fake-cache");
process.env.XDG_CACHE_HOME = fakeCacheHome;
defaultModelsDir = path.join(fakeCacheHome, "qmd", "models");
await fs.mkdir(defaultModelsDir, { recursive: true });
await fs.writeFile(path.join(defaultModelsDir, "model.bin"), "fake-model");
customModelsDir = path.join(stateDir, "agents", agentId, "qmd", "xdg-cache", "qmd", "models");
});
afterEach(() => {
if (savedXdgCacheHome === undefined) {
delete process.env.XDG_CACHE_HOME;
} else {
process.env.XDG_CACHE_HOME = savedXdgCacheHome;
}
});
it("symlinks default model cache into custom XDG_CACHE_HOME on first run", async () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy();
const stat = await fs.lstat(customModelsDir);
expect(stat.isSymbolicLink()).toBe(true);
const target = await fs.readlink(customModelsDir);
expect(target).toBe(defaultModelsDir);
// Models are accessible through the symlink.
const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8");
expect(content).toBe("fake-model");
await manager!.close();
});
it("does not overwrite existing models directory", async () => {
// Pre-create the custom models dir with different content.
await fs.mkdir(customModelsDir, { recursive: true });
await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom");
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy();
// Should still be a real directory, not a symlink.
const stat = await fs.lstat(customModelsDir);
expect(stat.isSymbolicLink()).toBe(false);
expect(stat.isDirectory()).toBe(true);
// Custom content should be preserved.
const content = await fs.readFile(path.join(customModelsDir, "custom-model.bin"), "utf-8");
expect(content).toBe("custom");
await manager!.close();
});
it("skips symlink when no default models exist", async () => {
// Remove the default models dir.
await fs.rm(defaultModelsDir, { recursive: true, force: true });
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy();
// Custom models dir should not exist (no symlink created).
await expect(fs.lstat(customModelsDir)).rejects.toThrow();
expect(logWarnMock).not.toHaveBeenCalledWith(
expect.stringContaining("failed to symlink qmd models directory"),
);
await manager!.close();
});
});
});
async function waitForCondition(check: () => boolean, timeoutMs: number): Promise<void> {

View File

@ -144,6 +144,14 @@ export class QmdMemoryManager implements MemorySearchManager {
await fs.mkdir(this.xdgCacheHome, { recursive: true });
await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
// QMD stores its ML models under $XDG_CACHE_HOME/qmd/models/. Because we
// override XDG_CACHE_HOME to isolate the index per-agent, qmd would not
// find models installed at the default location (~/.cache/qmd/models/) and
// would attempt to re-download them on every invocation. Symlink the
// default models directory into our custom cache so the index stays
// isolated while models are shared.
await this.symlinkSharedModels();
this.bootstrapCollections();
await this.ensureCollections();
@ -465,6 +473,68 @@ export class QmdMemoryManager implements MemorySearchManager {
}
}
/**
* Symlink the default QMD models directory into our custom XDG_CACHE_HOME so
* that the pre-installed ML models (~/.cache/qmd/models/) are reused rather
* than re-downloaded for every agent. If the default models directory does
* not exist, or a models directory/symlink already exists in the target, this
* is a no-op.
*/
private async symlinkSharedModels(): Promise<void> {
// process.env is never modified — only this.env (passed to child_process
// spawn) overrides XDG_CACHE_HOME. So reading it here gives us the
// user's original value, which is where `qmd` downloaded its models.
//
// On Windows, well-behaved apps (including Rust `dirs` / Go os.UserCacheDir)
// store caches under %LOCALAPPDATA% rather than ~/.cache. Fall back to
// LOCALAPPDATA when XDG_CACHE_HOME is not set on Windows.
const defaultCacheHome =
process.env.XDG_CACHE_HOME ||
(process.platform === "win32" ? process.env.LOCALAPPDATA : undefined) ||
path.join(os.homedir(), ".cache");
const defaultModelsDir = path.join(defaultCacheHome, "qmd", "models");
const targetModelsDir = path.join(this.xdgCacheHome, "qmd", "models");
try {
// Check if the default models directory exists.
// Missing path is normal on first run and should be silent.
const stat = await fs.stat(defaultModelsDir).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
});
if (!stat?.isDirectory()) {
return;
}
// Check if something already exists at the target path
try {
await fs.lstat(targetModelsDir);
// Already exists (directory, symlink, or file) leave it alone
return;
} catch {
// Does not exist proceed to create symlink
}
// On Windows, creating directory symlinks requires either Administrator
// privileges or Developer Mode. Fall back to a directory junction which
// works without elevated privileges (junctions are always absolute-path,
// which is fine here since both paths are already absolute).
try {
await fs.symlink(defaultModelsDir, targetModelsDir, "dir");
} catch (symlinkErr: unknown) {
const code = (symlinkErr as NodeJS.ErrnoException).code;
if (process.platform === "win32" && (code === "EPERM" || code === "ENOTSUP")) {
await fs.symlink(defaultModelsDir, targetModelsDir, "junction");
} else {
throw symlinkErr;
}
}
log.debug(`symlinked qmd models: ${defaultModelsDir}${targetModelsDir}`);
} catch (err) {
// Non-fatal: if we can't symlink, qmd will fall back to downloading
log.warn(`failed to symlink qmd models directory: ${String(err)}`);
}
}
private async runQmd(
args: string[],
opts?: { timeoutMs?: number },