diff --git a/CHANGELOG.md b/CHANGELOG.md index c51d604c87d..2e177b835c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-helpers.iscompactionfailureerror.test.ts b/src/agents/pi-embedded-helpers.iscompactionfailureerror.test.ts index bbcf495fa1a..7158d19b990 100644 --- a/src/agents/pi-embedded-helpers.iscompactionfailureerror.test.ts +++ b/src/agents/pi-embedded-helpers.iscompactionfailureerror.test.ts @@ -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 => ({ - 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 = [ diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 6c69c593925..a3ad3460ed3 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -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")) ); } diff --git a/src/infra/warning-filter.test.ts b/src/infra/warning-filter.test.ts index 9ee2458ad26..9333d23da0c 100644 --- a/src/infra/warning-filter.test.ts +++ b/src/infra/warning-filter.test.ts @@ -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); + } }); }); diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 38ab9768da2..56b4784197a 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -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 { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index e7931c5a050..078f0e16ff8 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -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 { + // 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 },