mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:10:52 +00:00
fix(context-window): Tighten context limits and bound memory excerpts (#67277)
* Tighten context limits and bound memory excerpts * Align startup context defaults in config docs * Align qmd memory_get bounds with shared limits * Preserve qmd partial memory reads * Fix shared memory read type import * Add changelog entry for context bounds
This commit is contained in:
@@ -9,7 +9,14 @@ export type SearchImpl = (opts?: {
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
}) => Promise<unknown[]>;
|
||||
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
|
||||
export type MemoryReadResult = { text: string; path: string };
|
||||
export type MemoryReadResult = {
|
||||
text: string;
|
||||
path: string;
|
||||
truncated?: boolean;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
nextFrom?: number;
|
||||
};
|
||||
type MemoryBackend = "builtin" | "qmd";
|
||||
|
||||
let backend: MemoryBackend = "builtin";
|
||||
@@ -19,6 +26,8 @@ let searchImpl: SearchImpl = async () => [];
|
||||
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
});
|
||||
|
||||
const stubManager = {
|
||||
@@ -94,7 +103,12 @@ export function resetMemoryToolMockState(overrides?: {
|
||||
searchImpl = overrides?.searchImpl ?? (async () => []);
|
||||
readFileImpl =
|
||||
overrides?.readFileImpl ??
|
||||
(async (params: MemoryReadParams) => ({ text: "", path: params.relPath }));
|
||||
(async (params: MemoryReadParams) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
}));
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,92 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
from: 2,
|
||||
lines: 1,
|
||||
});
|
||||
expect(result).toEqual({ text: "line 2", path: relPath });
|
||||
expect(result).toEqual({
|
||||
text: "line 2\n\n[More content available. Use from=3 to continue.]",
|
||||
path: relPath,
|
||||
from: 2,
|
||||
lines: 1,
|
||||
truncated: true,
|
||||
nextFrom: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a default-sized excerpt when no line range is provided", async () => {
|
||||
const relPath = "memory/default-window.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 150 }, (_, index) => `line ${index + 1}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.path).toBe(relPath);
|
||||
expect(result.from).toBe(1);
|
||||
expect(result.lines).toBe(120);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBe(121);
|
||||
expect(result.text).toContain("line 1");
|
||||
expect(result.text).toContain("line 120");
|
||||
expect(result.text).not.toContain("line 121");
|
||||
expect(result.text).toContain("Use from=121 to continue.");
|
||||
});
|
||||
|
||||
it("returns a bounded window when from is provided without lines", async () => {
|
||||
const relPath = "memory/from-only.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 160 }, (_, index) => `line ${index + 1}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
from: 21,
|
||||
});
|
||||
|
||||
expect(result.from).toBe(21);
|
||||
expect(result.lines).toBe(120);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBe(141);
|
||||
expect(result.text).toContain("line 21");
|
||||
expect(result.text).toContain("line 140");
|
||||
expect(result.text).not.toContain("line 141");
|
||||
});
|
||||
|
||||
it("honors injected defaultLines and maxChars overrides", async () => {
|
||||
const relPath = "memory/agent-limits.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 40 }, (_, index) => `line ${index + 1}: ${"x".repeat(40)}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
defaultLines: 5,
|
||||
maxChars: 220,
|
||||
});
|
||||
|
||||
expect(result.from).toBe(1);
|
||||
expect(result.lines).toBeLessThanOrEqual(5);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBeGreaterThan(1);
|
||||
expect(result.text).toContain("Use from=");
|
||||
});
|
||||
|
||||
it("returns empty text when the requested slice is past EOF", async () => {
|
||||
@@ -72,7 +157,87 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
from: 10,
|
||||
lines: 5,
|
||||
});
|
||||
expect(result).toEqual({ text: "", path: relPath });
|
||||
expect(result).toEqual({ text: "", path: relPath, from: 10, lines: 0 });
|
||||
});
|
||||
|
||||
it("caps returned text to the default max chars and exposes continuation metadata", async () => {
|
||||
const relPath = "memory/char-cap.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 200 }, (_, index) => `${index + 1}: ${"x".repeat(200)}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBeGreaterThan(1);
|
||||
expect(result.lines).toBeLessThan(120);
|
||||
expect(result.text.length).toBeLessThanOrEqual(12_000 + 64);
|
||||
expect(result.text).toContain("Use from=");
|
||||
});
|
||||
|
||||
it("suggests read fallback for pathological single-line truncation in workspace memory files", async () => {
|
||||
const relPath = "memory/oversized-line.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, `1: ${"x".repeat(20_000)}`, "utf-8");
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.lines).toBe(1);
|
||||
expect(result.nextFrom).toBeUndefined();
|
||||
expect(result.text).toContain("use read on the source file");
|
||||
expect(result.text).not.toContain("Use from=");
|
||||
});
|
||||
|
||||
it("does not advertise line continuation when a single oversized line is cut mid-line", async () => {
|
||||
const relPath = "memory/oversized-line-with-tail.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, [`1: ${"x".repeat(20_000)}`, "line 2"].join("\n"), "utf-8");
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.lines).toBe(1);
|
||||
expect(result.nextFrom).toBeUndefined();
|
||||
expect(result.text).not.toContain("Use from=");
|
||||
});
|
||||
|
||||
it("omits truncation metadata when the full excerpt fits and no more lines remain", async () => {
|
||||
const relPath = "memory/complete.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, ["alpha", "beta", "gamma"].join("\n"), "utf-8");
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "alpha\nbeta\ngamma",
|
||||
path: relPath,
|
||||
from: 1,
|
||||
lines: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty text when the file disappears after stat", async () => {
|
||||
@@ -121,6 +286,7 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
it("allows additional memory paths and blocks symlinks", async () => {
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content.");
|
||||
await fs.writeFile(path.join(extraDir, "oversized.md"), `1: ${"y".repeat(20_000)}`);
|
||||
|
||||
await expect(
|
||||
readMemoryFile({
|
||||
@@ -131,8 +297,18 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
).resolves.toEqual({
|
||||
path: "extra/extra.md",
|
||||
text: "Extra content.",
|
||||
from: 1,
|
||||
lines: 1,
|
||||
});
|
||||
|
||||
const oversized = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [extraDir],
|
||||
relPath: "extra/oversized.md",
|
||||
});
|
||||
expect(oversized.truncated).toBe(true);
|
||||
expect(oversized.text).not.toContain("use read on the source file");
|
||||
|
||||
const linkPath = path.join(extraDir, "linked.md");
|
||||
let symlinkOk = true;
|
||||
try {
|
||||
|
||||
@@ -254,6 +254,8 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
|
||||
path: actualRelative,
|
||||
text: "line-1\nline-2\nline-3",
|
||||
from: 1,
|
||||
lines: 3,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,6 +326,8 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
|
||||
path: `qmd/${collectionName}/${actualRelative}`,
|
||||
text: "vault memory",
|
||||
from: 1,
|
||||
lines: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -381,6 +385,8 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
|
||||
path: exactRelative,
|
||||
text: "exact slugified path",
|
||||
from: 1,
|
||||
lines: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2318,6 +2318,7 @@ describe("QmdMemoryManager", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
let expectedLimit = 0;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
@@ -2325,7 +2326,7 @@ describe("QmdMemoryManager", () => {
|
||||
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
|
||||
expect(callArgs).toMatchObject({
|
||||
query: "hello",
|
||||
limit: 6,
|
||||
limit: expectedLimit,
|
||||
minScore: 0,
|
||||
collection: "workspace-main",
|
||||
});
|
||||
@@ -2338,7 +2339,8 @@ describe("QmdMemoryManager", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
const { manager, resolved } = await createManager();
|
||||
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
await manager.close();
|
||||
});
|
||||
@@ -2548,6 +2550,7 @@ describe("QmdMemoryManager", () => {
|
||||
} as OpenClawConfig;
|
||||
|
||||
const selectors: string[] = [];
|
||||
let expectedLimit = 0;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
@@ -2564,7 +2567,7 @@ describe("QmdMemoryManager", () => {
|
||||
expect(selector).toBe("qmd.search");
|
||||
expect(callArgs).toMatchObject({
|
||||
query: "hello",
|
||||
limit: 6,
|
||||
limit: expectedLimit,
|
||||
minScore: 0,
|
||||
});
|
||||
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
|
||||
@@ -2574,7 +2577,8 @@ describe("QmdMemoryManager", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
const { manager, resolved } = await createManager();
|
||||
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
|
||||
expect(selectors).toEqual(["qmd.query", "qmd.search", "qmd.search"]);
|
||||
@@ -2603,6 +2607,7 @@ describe("QmdMemoryManager", () => {
|
||||
|
||||
const selectors: string[] = [];
|
||||
const collections: string[] = [];
|
||||
let expectedLimit = 0;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
@@ -2611,7 +2616,7 @@ describe("QmdMemoryManager", () => {
|
||||
collections.push(String(callArgs.collection ?? ""));
|
||||
expect(callArgs).toMatchObject({
|
||||
query: "hello",
|
||||
limit: 6,
|
||||
limit: expectedLimit,
|
||||
minScore: 0,
|
||||
});
|
||||
expect(callArgs).not.toHaveProperty("searches");
|
||||
@@ -2623,7 +2628,8 @@ describe("QmdMemoryManager", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
const { manager, resolved } = await createManager();
|
||||
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
|
||||
expect(selectors).toEqual(["qmd.hybrid_search", "qmd.hybrid_search"]);
|
||||
@@ -3584,13 +3590,45 @@ describe("QmdMemoryManager", () => {
|
||||
const { manager } = await createManager();
|
||||
|
||||
const result = await manager.readFile({ relPath, from: 10, lines: 3 });
|
||||
expect(result.text).toBe("line-10\nline-11\nline-12");
|
||||
expect(result).toEqual({
|
||||
path: relPath,
|
||||
text: "line-10\nline-11\nline-12\n\n[More content available. Use from=13 to continue.]",
|
||||
from: 10,
|
||||
lines: 3,
|
||||
truncated: true,
|
||||
nextFrom: 13,
|
||||
});
|
||||
expect(readFileSpy).not.toHaveBeenCalled();
|
||||
|
||||
await manager.close();
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns a bounded default excerpt for qmd memory reads without explicit lines", async () => {
|
||||
const relPath = path.join("memory", "default-window.md");
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, relPath),
|
||||
Array.from({ length: 150 }, (_, index) => `line-${index + 1}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { manager } = await createManager();
|
||||
|
||||
const result = await manager.readFile({ relPath });
|
||||
expect(result.path).toBe(relPath);
|
||||
expect(result.from).toBe(1);
|
||||
expect(result.lines).toBe(120);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBe(121);
|
||||
expect(result.text).toContain("line-1");
|
||||
expect(result.text).toContain("line-120");
|
||||
expect(result.text).not.toContain("line-121");
|
||||
expect(result.text).toContain("Use from=121 to continue.");
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("returns empty text when qmd files are missing before or during read", async () => {
|
||||
const relPath = path.join("memory", "qmd-window.md");
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
@@ -3620,7 +3658,7 @@ describe("QmdMemoryManager", () => {
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return realOpen(target, options);
|
||||
return await realOpen(target, options);
|
||||
});
|
||||
return () => openSpy.mockRestore();
|
||||
},
|
||||
@@ -4026,6 +4064,8 @@ describe("QmdMemoryManager", () => {
|
||||
expect(readResult).toEqual({
|
||||
path: "qmd/sessions-main/session-1.md",
|
||||
text: "# Session session-1\n\nsession canary\n",
|
||||
from: 1,
|
||||
lines: 4,
|
||||
});
|
||||
} finally {
|
||||
lstatSpy.mockRestore();
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
type SessionFileEntry,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
|
||||
import {
|
||||
buildMemoryReadResult,
|
||||
buildMemoryReadResultFromSlice,
|
||||
DEFAULT_MEMORY_READ_LINES,
|
||||
isFileMissingError,
|
||||
type MemoryReadResult,
|
||||
requireNodeSqlite,
|
||||
statRegularFile,
|
||||
type MemoryEmbeddingProbeResult,
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
type ResolvedQmdConfig,
|
||||
type ResolvedQmdMcporterConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { resolveAgentContextLimits } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import {
|
||||
localeLowercasePreservingWhitespace,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -1180,7 +1185,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<{ text: string; path: string }> {
|
||||
}): Promise<MemoryReadResult> {
|
||||
const relPath = params.relPath?.trim();
|
||||
if (!relPath) {
|
||||
throw new Error("path required");
|
||||
@@ -1193,18 +1198,38 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (statResult.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
const contextLimits = resolveAgentContextLimits(this.cfg, this.agentId);
|
||||
if (params.from !== undefined || params.lines !== undefined) {
|
||||
const partial = await this.readPartialText(absPath, params.from, params.lines);
|
||||
const requestedCount = Math.max(
|
||||
1,
|
||||
params.lines ?? contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
);
|
||||
const partial = await this.readPartialText(absPath, params.from, requestedCount);
|
||||
if (partial.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
return { text: partial.text, path: relPath };
|
||||
return buildMemoryReadResultFromSlice({
|
||||
selectedLines: partial.selectedLines,
|
||||
relPath,
|
||||
startLine: Math.max(1, params.from ?? 1),
|
||||
moreSourceLinesRemain: partial.moreSourceLinesRemain,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
suggestReadFallback: isDefaultMemoryPath(relPath),
|
||||
});
|
||||
}
|
||||
const full = await this.readFullText(absPath);
|
||||
if (full.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
return { text: full.text, path: relPath };
|
||||
return buildMemoryReadResult({
|
||||
content: full.text,
|
||||
relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
defaultLines: contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
suggestReadFallback: isDefaultMemoryPath(relPath),
|
||||
});
|
||||
}
|
||||
|
||||
status(): MemoryProviderStatus {
|
||||
@@ -1919,7 +1944,10 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
absPath: string,
|
||||
from?: number,
|
||||
lines?: number,
|
||||
): Promise<{ missing: true } | { missing: false; text: string }> {
|
||||
): Promise<
|
||||
| { missing: true }
|
||||
| { missing: false; selectedLines: string[]; moreSourceLinesRemain: boolean }
|
||||
> {
|
||||
const start = Math.max(1, from ?? 1);
|
||||
const count = Math.max(1, lines ?? Number.POSITIVE_INFINITY);
|
||||
let handle;
|
||||
@@ -1938,6 +1966,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
});
|
||||
const selected: string[] = [];
|
||||
let index = 0;
|
||||
let moreSourceLinesRemain = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
index += 1;
|
||||
@@ -1945,6 +1974,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
continue;
|
||||
}
|
||||
if (selected.length >= count) {
|
||||
moreSourceLinesRemain = true;
|
||||
break;
|
||||
}
|
||||
selected.push(line);
|
||||
@@ -1953,7 +1983,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
rl.close();
|
||||
await handle.close();
|
||||
}
|
||||
return { missing: false, text: selected.slice(0, count).join("\n") };
|
||||
return {
|
||||
missing: false,
|
||||
selectedLines: selected.slice(0, count),
|
||||
moreSourceLinesRemain,
|
||||
};
|
||||
}
|
||||
|
||||
private async readFullText(
|
||||
|
||||
@@ -60,7 +60,12 @@ beforeEach(() => {
|
||||
source: "memory" as const,
|
||||
},
|
||||
],
|
||||
readFileImpl: async (params: MemoryReadParams) => ({ text: "", path: params.relPath }),
|
||||
readFileImpl: async (params: MemoryReadParams) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,7 +160,7 @@ describe("memory tools", () => {
|
||||
|
||||
it("returns empty text without error when file does not exist (ENOENT)", async () => {
|
||||
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
|
||||
return { text: "", path: "memory/2026-02-19.md" };
|
||||
return { text: "", path: "memory/2026-02-19.md", from: 1, lines: 0 };
|
||||
});
|
||||
|
||||
const tool = createMemoryGetToolOrThrow();
|
||||
@@ -164,6 +169,8 @@ describe("memory tools", () => {
|
||||
expect(result.details).toEqual({
|
||||
text: "",
|
||||
path: "memory/2026-02-19.md",
|
||||
from: 1,
|
||||
lines: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,11 +183,37 @@ describe("memory tools", () => {
|
||||
expect(result.details).toEqual({
|
||||
text: "",
|
||||
path: "memory/2026-02-19.md",
|
||||
from: 1,
|
||||
lines: 120,
|
||||
});
|
||||
expect(getReadAgentMemoryFileMockCalls()).toBe(1);
|
||||
expect(getMemorySearchManagerMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns truncation metadata and a continuation notice for partial memory_get results", async () => {
|
||||
setMemoryBackend("builtin");
|
||||
setMemoryReadFileImpl(async (params: MemoryReadParams) => ({
|
||||
path: params.relPath,
|
||||
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
|
||||
from: params.from ?? 1,
|
||||
lines: 40,
|
||||
truncated: true,
|
||||
nextFrom: 41,
|
||||
}));
|
||||
|
||||
const tool = createMemoryGetToolOrThrow();
|
||||
const result = await tool.execute("call_partial", { path: "memory/partial.md" });
|
||||
|
||||
expect(result.details).toEqual({
|
||||
path: "memory/partial.md",
|
||||
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
|
||||
from: 1,
|
||||
lines: 40,
|
||||
truncated: true,
|
||||
nextFrom: 41,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists short-term recall events from memory_search tool hits", async () => {
|
||||
const workspaceDir = await createTempWorkspace("memory-tools-recall-");
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
type AnyAgentTool,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type {
|
||||
@@ -181,7 +180,7 @@ async function executeMemoryReadResult<T>(params: {
|
||||
export function createMemorySearchTool(options: {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
}): AnyAgentTool | null {
|
||||
}) {
|
||||
return createMemoryTool({
|
||||
options,
|
||||
label: "Memory Search",
|
||||
@@ -215,7 +214,9 @@ export function createMemorySearchTool(options: {
|
||||
});
|
||||
const searchStartedAt = Date.now();
|
||||
let rawResults: MemorySearchResult[] = [];
|
||||
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: "memory" }> = [];
|
||||
let surfacedMemoryResults: Array<
|
||||
Record<string, unknown> & { corpus: "memory"; score: number; path: string }
|
||||
> = [];
|
||||
let provider: string | undefined;
|
||||
let model: string | undefined;
|
||||
let fallback: unknown;
|
||||
@@ -320,13 +321,13 @@ export function createMemorySearchTool(options: {
|
||||
export function createMemoryGetTool(options: {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
}): AnyAgentTool | null {
|
||||
}) {
|
||||
return createMemoryTool({
|
||||
options,
|
||||
label: "Memory Get",
|
||||
name: "memory_get",
|
||||
description:
|
||||
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; `corpus=wiki` reads from registered compiled-wiki supplements. Use after search to pull only the needed lines and keep context small.",
|
||||
"Safe exact excerpt read from MEMORY.md or memory/*.md. Defaults to a bounded excerpt when lines are omitted, includes truncation/continuation info when more content exists, and `corpus=wiki` reads from registered compiled-wiki supplements.",
|
||||
parameters: MemoryGetSchema,
|
||||
execute:
|
||||
({ cfg, agentId }) =>
|
||||
|
||||
Reference in New Issue
Block a user