mirror of https://github.com/openclaw/openclaw.git
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:
parent
c984e6d8df
commit
e4651d6afa
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue