Memory: harden readFile ENOENT handling

This commit is contained in:
Vignesh Natarajan 2026-02-19 22:21:17 -08:00 committed by Vignesh
parent f3f47886ba
commit ec4198954a
3 changed files with 121 additions and 25 deletions

View File

@ -12,11 +12,16 @@ let searchImpl: () => Promise<unknown[]> = async () => [
source: "memory" as const,
},
];
let readFileImpl: () => Promise<unknown> = async () => "";
type MemoryReadParams = { relPath: string; from?: number; lines?: number };
type MemoryReadResult = { text: string; path: string };
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
text: "",
path: params.relPath,
});
const stubManager = {
search: vi.fn(async () => await searchImpl()),
readFile: vi.fn(async () => await readFileImpl()),
readFile: vi.fn(async (params: MemoryReadParams) => await readFileImpl(params)),
status: () => ({
backend,
files: 1,
@ -59,7 +64,7 @@ beforeEach(() => {
source: "memory" as const,
},
];
readFileImpl = async () => ""; // default: return empty string
readFileImpl = async (params: MemoryReadParams) => ({ text: "", path: params.relPath });
vi.clearAllMocks();
});
@ -170,7 +175,7 @@ describe("memory tools", () => {
});
it("does not throw when memory_get fails", async () => {
readFileImpl = async () => {
readFileImpl = async (_params: MemoryReadParams) => {
throw new Error("path required");
};
@ -191,7 +196,7 @@ describe("memory tools", () => {
});
it("returns empty text without error when file does not exist (ENOENT)", async () => {
readFileImpl = async () => {
readFileImpl = async (_params: MemoryReadParams) => {
return { text: "", path: "memory/2026-02-19.md" };
};

View File

@ -0,0 +1,77 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./index.js";
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
function createMemorySearchCfg(options: {
workspaceDir: string;
indexPath: string;
}): OpenClawConfig {
return {
agents: {
defaults: {
workspace: options.workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: options.indexPath, vector: { enabled: false } },
cache: { enabled: false },
query: { minScore: 0, hybrid: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
}
describe("MemoryIndexManager.readFile", () => {
let workspaceDir: string;
let indexPath: string;
let manager: MemoryIndexManager | null = null;
beforeEach(async () => {
resetEmbeddingMocks();
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-read-"));
indexPath = path.join(workspaceDir, "index.sqlite");
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
});
afterEach(async () => {
if (manager) {
await manager.close();
manager = null;
}
await fs.rm(workspaceDir, { recursive: true, force: true });
});
it("returns empty text when the requested file does not exist", async () => {
manager = await getRequiredMemoryIndexManager({
cfg: createMemorySearchCfg({ workspaceDir, indexPath }),
agentId: "main",
});
const relPath = "memory/2099-01-01.md";
const result = await manager.readFile({ relPath });
expect(result).toEqual({ text: "", path: relPath });
});
it("returns content slices when the file exists", async () => {
const relPath = "memory/2026-02-20.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, ["line 1", "line 2", "line 3"].join("\n"), "utf-8");
manager = await getRequiredMemoryIndexManager({
cfg: createMemorySearchCfg({ workspaceDir, indexPath }),
agentId: "main",
});
const result = await manager.readFile({ relPath, from: 2, lines: 1 });
expect(result).toEqual({ text: "line 2", path: relPath });
});
});

View File

@ -1,11 +1,20 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { Stats } from "node:fs";
import type { DatabaseSync } from "node:sqlite";
import { type FSWatcher } from "chokidar";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import fs from "node:fs/promises";
import path from "node:path";
import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import type { OpenClawConfig } from "../config/config.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./types.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
createEmbeddingProvider,
@ -20,14 +29,6 @@ import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
import { MemoryManagerEmbeddingOps } from "./manager-embedding-ops.js";
import { searchKeyword, searchVector } from "./manager-search.js";
import { extractKeywords } from "./query-expansion.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./types.js";
const SNIPPET_MAX_CHARS = 700;
const VECTOR_TABLE = "chunks_vec";
const FTS_TABLE = "chunks_fts";
@ -36,6 +37,15 @@ const BATCH_FAILURE_LIMIT = 2;
const log = createSubsystemLogger("memory");
function isFileMissingError(err: unknown): err is NodeJS.ErrnoException & { code: "ENOENT" } {
return Boolean(
err &&
typeof err === "object" &&
"code" in err &&
(err as Partial<NodeJS.ErrnoException>).code === "ENOENT",
);
}
const INDEX_CACHE = new Map<string, MemoryIndexManager>();
export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements MemorySearchManager {
@ -437,15 +447,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (!absPath.endsWith(".md")) {
throw new Error("path required");
}
let stat;
let stat: Stats;
try {
stat = await fs.lstat(absPath);
} catch (err: unknown) {
if (
err instanceof Error &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ENOENT"
) {
} catch (err) {
if (isFileMissingError(err)) {
return { text: "", path: relPath };
}
throw err;
@ -453,7 +459,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (stat.isSymbolicLink() || !stat.isFile()) {
throw new Error("path required");
}
const content = await fs.readFile(absPath, "utf-8");
let content: string;
try {
content = await fs.readFile(absPath, "utf-8");
} catch (err) {
if (isFileMissingError(err)) {
return { text: "", path: relPath };
}
throw err;
}
if (!params.from && !params.lines) {
return { text: content, path: relPath };
}